Data Display

Streak Calendar

A daily streak heatmap grid with today emphasized and a milestone badge at streak marks.

Install

npx shadcn@latest add @paragon/streak-calendar

streak-calendar.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Flame } from "lucide-react";
import { cn } from "@/lib/utils";

export interface StreakDay {
  /** Human date label used in the tooltip, e.g. "Jul 4". */
  label: string;
  /** Activity count for the day. 0 = no activity. Drives the intensity. */
  value: number;
  /** Marks the current day for emphasis. */
  today?: boolean;
}

export interface StreakCalendarProps
  extends Omit<React.ComponentProps<"div">, "color"> {
  /** Chronological list of days, oldest first. */
  days: StreakDay[];
  /** Weekday labels, Sunday-first. Set to null to hide. */
  weekdays?: string[] | null;
  /** Accent color the intensity ramp is mixed from. */
  accent?: string;
  /** Cell edge length in px. */
  cellSize?: number;
  /** Metric noun for the tooltip, e.g. "deliveries". */
  unit?: string;
  /** Suppresses the cell fill-in stagger. */
  static?: boolean;
}

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

/**
 * A daily-activity heatmap (GitHub-style). Days are packed into week columns
 * aligned to weekday rows; each cell's fill intensity is bucketed from its
 * value and mixed off the accent so both themes stay legible. Cells are
 * focusable and carry a date+value tooltip. The current and longest streaks are
 * computed from the series. Cells fade in with a short in-view stagger; reduced
 * motion fills them with no movement.
 */
export function StreakCalendar({
  days,
  weekdays = WEEKDAYS,
  accent = "var(--color-success)",
  cellSize = 14,
  unit = "active",
  static: isStatic = false,
  className,
  ...props
}: StreakCalendarProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const animate = !isStatic && !reducedMotion;
  const shown = !animate || inView;
  const uid = React.useId();

  const maxValue = React.useMemo(
    () => days.reduce((m, d) => Math.max(m, d.value), 0),
    [days],
  );

  // Streak math (a day counts toward a streak when value > 0).
  const { currentStreak, longestStreak, activeDays } = React.useMemo(() => {
    let current = 0;
    let longest = 0;
    let run = 0;
    let active = 0;
    for (let i = 0; i < days.length; i++) {
      if (days[i].value > 0) {
        run += 1;
        active += 1;
        if (run > longest) longest = run;
      } else {
        run = 0;
      }
    }
    // Current = trailing run.
    for (let i = days.length - 1; i >= 0; i--) {
      if (days[i].value > 0) current += 1;
      else break;
    }
    return { currentStreak: current, longestStreak: longest, activeDays: active };
  }, [days]);

  // Column-major packing: consecutive weeks become columns; the first column is
  // offset by the starting weekday so rows line up under the weekday labels.
  const startOffset = 0; // days[0] is treated as the top-left (row 0).
  const totalSlots = startOffset + days.length;
  const columns = Math.ceil(totalSlots / 7);

  // Intensity bucket 0–4 → mixed accent alpha.
  const bucketColor = (value: number) => {
    if (value <= 0 || maxValue <= 0) return "var(--color-secondary)";
    const t = value / maxValue; // 0–1
    const bucket = Math.min(4, Math.max(1, Math.ceil(t * 4)));
    const mix = [0, 28, 48, 72, 100][bucket];
    return `color-mix(in oklch, ${accent} ${mix}%, var(--color-secondary))`;
  };

  const gap = Math.max(2, Math.round(cellSize * 0.22));
  const radius = Math.max(2, Math.round(cellSize * 0.28));
  const hasData = days.length > 0;

  return (
    <div
      ref={ref}
      data-slot="streak-calendar"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-streak-calendar" precedence="paragon">{`
        @keyframes streak-cell-in {
          from { opacity: 0; transform: scale(0.55); }
          to { opacity: 1; transform: scale(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-streak-cell] { animation: none !important; opacity: 1 !important; transform: none !important; }
        }
      `}</style>

      {/* Header */}
      <div className="mb-4 flex items-center justify-between gap-3">
        <div className="flex items-center gap-2.5">
          <span
            className={cn(
              "flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors duration-200",
              currentStreak > 0
                ? "bg-warning/15 text-warning"
                : "bg-secondary text-muted-foreground",
            )}
          >
            <Flame className="size-4.5" />
          </span>
          <div className="min-w-0">
            <p className="text-lg font-semibold leading-none tabular-nums">
              {currentStreak}
              <span className="ml-1 text-sm font-normal text-muted-foreground">
                day streak
              </span>
            </p>
            <p className="mt-1 text-xs text-muted-foreground tabular-nums">
              Longest {longestStreak} · {activeDays}/{days.length} active
            </p>
          </div>
        </div>
        {/* Intensity legend */}
        <div className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
          <span>Less</span>
          <span className="flex gap-0.5">
            {[0, 1, 2, 3, 4].map((b) => (
              <span
                key={b}
                aria-hidden
                className="size-2.5 rounded-[3px]"
                style={{
                  background:
                    b === 0
                      ? "var(--color-secondary)"
                      : `color-mix(in oklch, ${accent} ${[0, 28, 48, 72, 100][b]}%, var(--color-secondary))`,
                }}
              />
            ))}
          </span>
          <span>More</span>
        </div>
      </div>

      {hasData ? (
        <div className="flex gap-1.5">
          {/* Weekday rail */}
          {weekdays && (
            <div
              aria-hidden
              className="flex flex-col justify-between pr-0.5 text-[10px] text-muted-foreground"
              style={{ gap: `${gap}px`, height: 7 * cellSize + 6 * gap }}
            >
              {weekdays.map((w, i) => (
                <span
                  key={w}
                  className="flex items-center leading-none tabular-nums"
                  style={{ height: cellSize }}
                >
                  {/* Show every other label to avoid crowding at small sizes. */}
                  {i % 2 === 1 ? w : ""}
                </span>
              ))}
            </div>
          )}

          {/* Heatmap grid, column-major */}
          <div
            role="grid"
            aria-label={`Activity heatmap: ${activeDays} of ${days.length} days active, current streak ${currentStreak} days`}
            className="grid grid-flow-col"
            style={{
              gridTemplateRows: `repeat(7, ${cellSize}px)`,
              gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
              gap: `${gap}px`,
            }}
          >
            {days.map((day, i) => {
              const slot = startOffset + i;
              const row = slot % 7;
              const col = Math.floor(slot / 7);
              const delay = animate ? (col * 3 + row) * 12 : 0;
              return (
                <StreakCell
                  key={i}
                  day={day}
                  unit={unit}
                  color={bucketColor(day.value)}
                  cellSize={cellSize}
                  radius={radius}
                  gridRow={row + 1}
                  gridColumn={col + 1}
                  animate={animate}
                  shown={shown}
                  delay={delay}
                  uid={`${uid}-${i}`}
                />
              );
            })}
          </div>
        </div>
      ) : (
        <div className="flex h-24 items-center justify-center rounded-lg bg-secondary/40 text-xs text-muted-foreground">
          No activity recorded yet.
        </div>
      )}
    </div>
  );
}

interface StreakCellProps {
  day: StreakDay;
  unit: string;
  color: string;
  cellSize: number;
  radius: number;
  gridRow: number;
  gridColumn: number;
  animate: boolean;
  shown: boolean;
  delay: number;
  uid: string;
}

function StreakCell({
  day,
  unit,
  color,
  cellSize,
  radius,
  gridRow,
  gridColumn,
  animate,
  shown,
  delay,
  uid,
}: StreakCellProps) {
  const [open, setOpen] = React.useState(false);
  const active = day.value > 0;

  return (
    <div
      style={{ gridRow, gridColumn }}
      className="relative"
      onMouseEnter={() => setOpen(true)}
      onMouseLeave={() => setOpen(false)}
    >
      <button
        type="button"
        role="gridcell"
        aria-describedby={open ? uid : undefined}
        aria-label={`${day.label}: ${day.value} ${unit}`}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
        data-streak-cell
        className={cn(
          "block outline-none transition-[filter] duration-150 ease-out",
          "hover:brightness-110 focus-visible:brightness-110",
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
          day.today && "ring-1 ring-primary ring-offset-1 ring-offset-card",
        )}
        style={{
          width: cellSize,
          height: cellSize,
          borderRadius: radius,
          background: color,
          ...(animate && shown && active
            ? { animation: `streak-cell-in 220ms var(--ease-out) ${delay}ms both` }
            : animate && !shown && active
              ? { opacity: 0 }
              : undefined),
        }}
      />

      {/* Tooltip */}
      <div
        id={uid}
        role="tooltip"
        className={cn(
          "pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 -translate-x-1/2",
          "whitespace-nowrap rounded-md bg-primary px-2.5 py-1.5 text-xs text-primary-foreground shadow-overlay",
          "origin-bottom transition-[opacity,transform] duration-150 ease-out",
          open ? "opacity-100" : "translate-y-1 scale-95 opacity-0",
        )}
      >
        <span className="font-medium tabular-nums">{day.value}</span>{" "}
        <span className="text-primary-foreground/80">{unit}</span>
        <span className="mx-1 text-primary-foreground/40">·</span>
        <span className="tabular-nums text-primary-foreground/80">{day.label}</span>
        <span
          aria-hidden
          className="absolute top-full left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-primary"
        />
      </div>
    </div>
  );
}