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.
npx shadcn@latest add @paragon/activity-heat-strip"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>
);
}