Calendar heatmap read as a tide table — one column per week, depth carried by a single-hue ramp mixed from the accent token, with arrow-key navigation.
npx shadcn add https://design.helpmarq.com /r/heatmap-calendar-tide.jsonregistry/loud/heatmap-calendar-tide/component.tsx"use client";
import { useMemo, useRef, useState } from "react";
export type TideCell = { date: string; value: number };
// A year of daily values read as a tide table: one column per week, one row per
// weekday, depth carried by a single-hue sequential ramp rather than a rainbow.
//
// Every step is mixed at runtime from --accent into --background, so the ramp is
// re-stepped by the theme instead of being two hardcoded palettes: the same 5
// stops read as ink-on-paper in light and as lit water in dark. A day with no
// reading is not step 0 of the ramp — it is border-coloured slack, so "nothing
// recorded" never looks like "recorded, and low".
const STEPS = [18, 34, 52, 74, 100];
const SLACK = "color-mix(in oklab, var(--border) 70%, var(--background))";
const depth = (step: number) =>
`color-mix(in oklab, var(--accent) ${STEPS[step]}%, var(--background))`;
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export function TideLedger({
data,
weeks = 26,
cell = 13,
gap = 3,
label = "Daily readings",
onSelect,
className = "",
}: {
data: TideCell[];
weeks?: number;
/** px edge of one day cell */
cell?: number;
gap?: number;
label?: string;
onSelect?: (cell: TideCell) => void;
className?: string;
}) {
const [active, setActive] = useState<number | null>(null);
const [cursor, setCursor] = useState(0);
const gridRef = useRef<HTMLDivElement>(null);
// Column-major: index = week * 7 + weekday, which is the order the DOM is in,
// so arrow-key movement is plain index arithmetic rather than a lookup.
const cells = useMemo(() => data.slice(-(weeks * 7)), [data, weeks]);
const max = useMemo(() => Math.max(1, ...cells.map((c) => c.value)), [cells]);
const stepOf = (value: number) =>
value <= 0 ? -1 : Math.min(STEPS.length - 1, Math.floor((value / max) * STEPS.length));
const move = (delta: number) => {
const next = Math.min(cells.length - 1, Math.max(0, cursor + delta));
setCursor(next);
setActive(next);
(gridRef.current?.children[next] as HTMLElement | undefined)?.focus();
};
const shown = active !== null ? cells[active] : null;
return (
<figure className={["inline-flex flex-col gap-3", className].join(" ")}>
<figcaption className="flex items-baseline justify-between gap-6 font-mono text-[11px] uppercase tracking-[0.18em] text-muted">
<span>{label}</span>
{/* The readout is the direct label: one value, the hovered one, rather
than a number stamped on 182 cells. */}
<span className="text-foreground/80" aria-live="polite">
{shown ? `${shown.date} · ${shown.value}` : `peak ${max}`}
</span>
</figcaption>
<div className="flex gap-2">
<div
className="grid shrink-0 text-[10px] text-muted"
style={{ gridTemplateRows: `repeat(7, ${cell}px)`, rowGap: gap }}
aria-hidden
>
{DAYS.map((d, i) => (
<span key={d} className="leading-none" style={{ fontSize: 9, lineHeight: `${cell}px` }}>
{i % 2 === 0 ? d[0] : ""}
</span>
))}
</div>
<div
ref={gridRef}
role="grid"
aria-label={label}
className="grid"
style={{
gridTemplateRows: `repeat(7, ${cell}px)`,
gridAutoFlow: "column",
gridAutoColumns: `${cell}px`,
gap,
}}
onKeyDown={(e) => {
const delta =
e.key === "ArrowRight" ? 7 : e.key === "ArrowLeft" ? -7 : e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (!delta) return;
e.preventDefault();
move(delta);
}}
onMouseLeave={() => setActive(null)}
>
{cells.map((c, i) => {
const step = stepOf(c.value);
return (
<button
key={c.date}
type="button"
role="gridcell"
// Roving tabindex: the grid is one tab stop, arrows walk it.
tabIndex={i === cursor ? 0 : -1}
aria-label={`${c.date}: ${c.value}`}
onMouseEnter={() => setActive(i)}
onFocus={() => {
setCursor(i);
setActive(i);
}}
onClick={() => onSelect?.(c)}
className="rounded-[2px] transition-[transform,box-shadow] duration-150 ease-out hover:scale-[1.35] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent motion-reduce:transition-none motion-reduce:hover:scale-100"
style={{
background: step < 0 ? SLACK : depth(step),
// 2px surface ring so neighbouring depths never bleed into one
// continuous field.
boxShadow: active === i ? "0 0 0 2px var(--background), 0 0 0 3px var(--accent)" : "none",
}}
/>
);
})}
</div>
</div>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-muted">
<span>slack</span>
<span className="h-[10px] w-[10px] rounded-[2px]" style={{ background: SLACK }} />
{STEPS.map((_, i) => (
<span key={i} className="h-[10px] w-[10px] rounded-[2px]" style={{ background: depth(i) }} />
))}
<span>flood</span>
</div>
</figure>
);
}
A dense per-day (or per-slot) magnitude grid — contribution calendars, uptime, volume per weekday — where the shape of the whole period matters more than any single value. Reach for histogram-live-grain when the question is the distribution of a sample rather than its position in time.
A calendar heatmap laid out column-major — one column per week, seven rows for weekdays — where magnitude is carried by a five-step sequential ramp of a single hue rather than a rainbow or a red-to-green scale. The ramp is not two hardcoded palettes: each step is mixed at runtime with color-mix(in oklab, var(--accent) N%, var(--background)) at 18/34/52/74/100, so the same five stops resolve to ink-on-paper depths in light and to lit water in dark, and a consumer who re-tokenises the accent gets a re-stepped ramp for free. A day with no reading is deliberately NOT step zero of that ramp — it is drawn in a border-derived slack colour, because a bottom-step cell and an empty cell mean different things and a viewer cannot be asked to tell them apart by lightness alone. The legend runs slack → five depths → flood, and the only numeric readout is the caption, which reports the hovered or focused day and otherwise reports the peak: one direct label rather than a number stamped on 182 cells. Interaction is a real grid, not a wall of tooltips — the whole heatmap is one tab stop via roving tabindex, arrow keys walk it (left/right by week, up/down by weekday) with focus moved imperatively so the browser scrolls the cell into view, every cell carries an aria-label of its date and value, and the caption is aria-live so a screen-reader user hears the value the sighted user reads. The hovered or focused cell lifts 35% and takes a two-ring highlight — a background-coloured ring first, then an accent ring — so it separates from its neighbours instead of bleeding into one continuous field. Under prefers-reduced-motion the lift and its transition are dropped; the ring, the readout and the keyboard grid are untouched.