Activity Heat Strip
Data Display

Activity Heat Strip

A single-row contribution heat strip for table rows and card headers, with a 4-step normalized intensity scale, staggered first-view reveal, and a shared keyboard-navigable tooltip.

Install

npx shadcn@latest add @paragon/activity-heat-strip

activity-heat-strip.tsx

"use client";

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

export interface ActivityDay {
  /** ISO date, e.g. "2026-06-14". */
  date: string;
  count: number;
}

export interface ActivityHeatStripProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** One entry per day, oldest first. Zero-count days still render. */
  data: ActivityDay[];
  /** Cell edge in px. */
  cellSize?: number;
  /** Gap between cells in px. */
  gap?: number;
  /** Base hue for the intensity scale. Any CSS color. */
  color?: string;
  /** Noun for tooltips and the summary, e.g. "deploys". */
  noun?: string;
  formatCount?: (count: number) => string;
  /** Total + date range line under the strip. */
  showSummary?: boolean;
  /** Renders cells in place with no staggered reveal. */
  static?: boolean;
}

const LEVELS = 4;

function levelOf(count: number, max: number): number {
  if (count <= 0 || max <= 0) return 0;
  return Math.max(1, Math.ceil((count / max) * LEVELS));
}

function fillFor(level: number, color: string): string {
  if (level === 0)
    return "color-mix(in oklch, var(--color-foreground) 8%, transparent)";
  const pct = [0, 30, 52, 74, 100][level];
  return `color-mix(in oklch, ${color} ${pct}%, transparent)`;
}

const dayFormat = new Intl.DateTimeFormat("en-US", {
  weekday: "short",
  month: "short",
  day: "numeric",
});
const rangeFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
});

/** Parse as local noon so the strip never shifts a day across timezones. */
function parseDay(iso: string): Date {
  return new Date(`${iso}T12:00:00`);
}

/**
 * A single-row activity heat strip — the inline cousin of the contribution
 * calendar, sized for table rows and card headers. Intensity is a 4-step
 * single-hue scale normalized to the busiest day. Cells stagger-fade in on
 * first view; one shared tooltip follows the hovered or keyboard-active
 * day (the strip is a single tab stop — arrows walk days, Home/End jump)
 * and announces itself to screen readers, re-anchoring at the edges so it
 * never overhangs the strip.
 */
export function ActivityHeatStrip({
  data,
  cellSize = 10,
  gap = 3,
  color = "var(--color-success)",
  noun = "events",
  formatCount = (count) => count.toLocaleString("en-US"),
  showSummary = false,
  static: isStatic = false,
  className,
  ...props
}: ActivityHeatStripProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const reducedMotion = useReducedMotion() ?? false;
  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const [active, setActive] = React.useState<number | null>(null);
  const [focusVisible, setFocusVisible] = React.useState(false);

  const max = data.reduce((m, d) => Math.max(m, d.count), 0);
  const total = data.reduce((sum, d) => sum + d.count, 0);
  const pitch = cellSize + gap;
  const stripWidth = data.length > 0 ? data.length * pitch - gap : 0;

  const labelFor = (day: ActivityDay) =>
    `${formatCount(day.count)} ${noun} · ${dayFormat.format(parseDay(day.date))}`;

  const pickFromPointer = (clientX: number) => {
    const strip = ref.current;
    if (!strip) return;
    const { left } = strip.getBoundingClientRect();
    const index = Math.floor((clientX - left) / pitch);
    setActive(index >= 0 && index < data.length ? index : null);
  };

  if (data.length === 0) {
    return (
      <div
        data-slot="activity-heat-strip"
        className={cn(
          "flex items-center rounded-md bg-muted/40 px-2.5 text-[11px] text-muted-foreground",
          className,
        )}
        style={{ height: Math.max(cellSize + 8, 22) }}
        {...props}
      >
        No activity yet
      </div>
    );
  }

  const activeDay = active !== null ? data[active] : null;
  const anchor =
    active === null
      ? "middle"
      : active * pitch < stripWidth / 3
        ? "start"
        : active * pitch > (stripWidth * 2) / 3
          ? "end"
          : "middle";

  return (
    <div
      data-slot="activity-heat-strip"
      className={cn("w-max max-w-full", className)}
      {...props}
    >
      <div className="relative">
        <div
          ref={ref}
          role="group"
          tabIndex={0}
          aria-label={`${formatCount(total)} ${noun} over the last ${data.length} days. Use arrow keys to explore.`}
          className="flex w-max rounded-[3px] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          style={{ gap }}
          onPointerMove={(e) => pickFromPointer(e.clientX)}
          onPointerDown={(e) => pickFromPointer(e.clientX)}
          onPointerLeave={(e) => {
            if (e.pointerType === "touch") return;
            if (!focusVisible) setActive(null);
          }}
          onFocus={() => {
            setFocusVisible(true);
            setActive((prev) => prev ?? data.length - 1);
          }}
          onBlur={() => {
            setFocusVisible(false);
            setActive(null);
          }}
          onKeyDown={(e) => {
            if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
              e.preventDefault();
              setActive((prev) => {
                const cur = prev ?? data.length - 1;
                const next = e.key === "ArrowRight" ? cur + 1 : cur - 1;
                return Math.max(0, Math.min(data.length - 1, next));
              });
            } else if (e.key === "Home" || e.key === "End") {
              e.preventDefault();
              setActive(e.key === "Home" ? 0 : data.length - 1);
            } else if (e.key === "Escape") {
              setActive(null);
            }
          }}
        >
          {data.map((day, index) => {
            const level = levelOf(day.count, max);
            const delay = animate ? Math.min(index * 8, 480) : 0;
            return (
              <span
                key={day.date}
                aria-hidden
                className="pointer-events-none block shrink-0"
                style={{
                  width: cellSize,
                  height: cellSize,
                  borderRadius: Math.max(2, Math.round(cellSize * 0.22)),
                  background: fillFor(level, color),
                  boxShadow:
                    active === index
                      ? "0 0 0 1.5px color-mix(in oklch, var(--color-foreground) 55%, transparent)"
                      : undefined,
                  opacity: drawn ? 1 : 0,
                  transform: drawn ? "scale(1)" : "scale(0.85)",
                  transition: animate
                    ? `opacity 200ms var(--ease-out) ${delay}ms, transform 200ms var(--ease-out) ${delay}ms`
                    : undefined,
                }}
              />
            );
          })}
        </div>

        {/* Singleton tooltip — follows the active day, never overhangs. */}
        {activeDay && (
          <div
            aria-hidden
            className="pointer-events-none absolute top-0 z-10 rounded-md bg-popover px-2 py-1 text-[11px] font-medium whitespace-nowrap text-popover-foreground shadow-overlay tabular-nums"
            style={{
              left: (active ?? 0) * pitch + cellSize / 2,
              transform: `translate(${
                anchor === "start"
                  ? `${-cellSize / 2 - 2}px`
                  : anchor === "end"
                    ? `calc(-100% + ${cellSize / 2 + 2}px)`
                    : "-50%"
              }, calc(-100% - 6px))`,
            }}
          >
            {labelFor(activeDay)}
          </div>
        )}

        <span role="status" aria-live="polite" className="sr-only">
          {activeDay ? labelFor(activeDay) : ""}
        </span>
      </div>

      {showSummary && (
        <div className="mt-2 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
          <span className="tabular-nums">
            <span className="font-medium text-foreground">
              {formatCount(total)}
            </span>{" "}
            {noun} · {rangeFormat.format(parseDay(data[0].date))}{" "}
            {rangeFormat.format(parseDay(data[data.length - 1].date))}
          </span>
          <span className="flex items-center gap-1.5">
            Less
            <span className="flex gap-[3px]" aria-hidden>
              {Array.from({ length: LEVELS + 1 }, (_, level) => (
                <span
                  key={level}
                  className="rounded-[2px]"
                  style={{
                    width: 9,
                    height: 9,
                    background: fillFor(level, color),
                  }}
                />
              ))}
            </span>
            More
          </span>
        </div>
      )}
    </div>
  );
}