Component
Activity heatmap
A weekly grid chart that shows daily activity as shades of intensity.
- #arc
- #component
- #chart
- #heatmap
- #calendar
- #data
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/activity-heatmap.jsonFiles
components/arc/activity-heatmap/activity-heatmap.module.csscomponents/arc/activity-heatmap/activity-heatmap.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownactivity-heatmap.module.cssactivity-heatmap.module.cssactivity-heatmap.module.csscss
/* Levels are tints of the accent over a neutral empty square, so the grid follows the selected accent and both themes. */
.root { --level-0: color-mix(in oklab, var(--foreground) 6.5%, var(--surface)); --level-1: color-mix(in oklab, var(--accent) 26%, var(--level-0)); --level-2: color-mix(in oklab, var(--accent) 50%, var(--level-0)); --level-3: color-mix(in oklab, var(--accent) 76%, var(--level-0)); --level-4: var(--accent); container-type: inline-size; position: relative; display: grid; min-width: 0; gap: var(--space-4); color: var(--foreground); }
.header { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-4); }
.summary { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; column-gap: .3em; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
.total { color: var(--foreground); font-weight: 500; white-space: nowrap; }
.period { white-space: nowrap; }
.actions { display: flex; flex: none; align-items: center; }
.rollingFrame { display: inline-flex; justify-content: flex-end; overflow: clip; overflow-clip-margin: .2em; }
.rolling { display: inline-flex; flex: none; }
/* Each place is its own window, so a turning digit clips at the line instead of brushing the next line of text. */
.place { position: relative; display: inline-flex; overflow: clip; overflow-clip-margin: .15em; }
.placeChar, .riseLine { display: inline-block; }
.rise { position: relative; display: inline-flex; }
/* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */
.scroller { direction: rtl; overflow-x: auto; overflow-y: hidden; margin: -6px; padding: 6px; overscroll-behavior-x: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
.canvas { --gap: 3px; --label: 30px; --cell: clamp(9px, calc((100cqi - var(--label) - var(--gap) * 53) / 53), 15px); --step: calc(var(--cell) + var(--gap)); direction: ltr; display: grid; width: max-content; grid-template-columns: var(--label) auto; grid-template-rows: auto auto; column-gap: var(--gap); row-gap: var(--space-2); }
.months { position: relative; grid-row: 1; grid-column: 2; height: 1rem; color: var(--text-muted); font-size: var(--text-xs); line-height: 1rem; }
/* Month labels glide to their new week when a range starts on a different weekday. */
.month { position: absolute; top: 0; left: 0; white-space: nowrap; transform: translateX(calc(var(--col) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring); }
/* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */
.weekdays { position: sticky; z-index: 1; left: -6px; display: grid; grid-row: 1 / 3; grid-column: 1; grid-template-rows: repeat(7, var(--cell)); align-content: end; row-gap: var(--gap); margin: 0 calc(var(--gap) * -1) 0 -6px; padding: 0 var(--gap) 0 6px; background: var(--heatmap-surface, var(--surface)); color: var(--text-muted); font-size: var(--text-xs); }
.weekday { display: flex; height: var(--cell); align-items: center; white-space: nowrap; line-height: 1; }
.plot { position: relative; grid-row: 2; grid-column: 2; }
.grid { display: grid; gap: var(--gap); outline: none; }
.row { display: flex; gap: var(--gap); }
/* One square per day. The wave delay rides a custom property so a reveal and a recolor sweep the same diagonal. */
.cell { --delay: var(--wave); display: block; width: var(--cell); height: var(--cell); flex: none; border-radius: calc(var(--cell) * .28); background: var(--level-0); outline: 1.5px solid transparent; outline-offset: 1px; cursor: default; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-moderate) var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity var(--duration-fast) var(--ease-standard); }
.grid[data-direction="back"] .cell { --delay: var(--wave-back); }
.cell[data-level="1"] { background: var(--level-1); }
.cell[data-level="2"] { background: var(--level-2); }
.cell[data-level="3"] { background: var(--level-3); }
.cell[data-level="4"] { background: var(--level-4); }
/* Days outside the range shrink away in the sweep and grow back when a range needs them. */
.cell[data-empty] { transform: scale(0); background: var(--level-0); }
.grid[data-reveal="hidden"] .cell { opacity: 0; transform: scale(.5); transition: none; }
.grid[data-reveal="hidden"] .cell[data-empty] { transform: scale(0); }
.grid[data-reveal="revealing"] .cell { transition: background-color var(--duration-moderate) var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity var(--duration-moderate) var(--ease-enter) var(--delay); }
.grid[data-highlight] .cell:not([data-empty]) { opacity: .2; }
.grid[data-highlight="0"] .cell[data-level="0"], .grid[data-highlight="1"] .cell[data-level="1"], .grid[data-highlight="2"] .cell[data-level="2"], .grid[data-highlight="3"] .cell[data-level="3"], .grid[data-highlight="4"] .cell[data-level="4"] { opacity: 1; }
@media (hover: hover) and (pointer: fine) { .cell:not([data-empty]):hover { outline-color: color-mix(in oklab, var(--foreground) 45%, transparent); } }
/* The selection ring glides between days, and follows its day when the range changes. */
.ring { position: absolute; top: -3px; left: -3px; width: calc(var(--cell) + 6px); height: calc(var(--cell) + 6px); border: 1.5px solid var(--foreground); border-radius: calc(var(--cell) * .28 + 3px); opacity: 0; pointer-events: none; transform: translate(calc(var(--col) * var(--step)), calc(var(--row) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
.ring[data-shown] { opacity: 1; }
.legend { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-3); color: var(--text-muted); font-size: var(--text-xs); }
/* The caption names the previewed level; it sits apart from the scale so its changing width never nudges the swatches. */
.caption { position: relative; min-width: 0; overflow: hidden; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; text-overflow: ellipsis; }
.scale { display: flex; flex: none; align-items: center; gap: var(--space-2); }
.captionShort { display: none; }
/* Narrow cards keep the day count and let the highlighted squares say which level it is. */
@container (max-width: 440px) { .captionLong { display: none; } .captionShort { display: inline; } }
.legendText { line-height: 1; }
.swatches { display: flex; }
/* The visible square stays the size of a day while the button keeps a comfortable target around it. */
.swatch { position: relative; display: grid; width: 15px; height: 20px; place-items: center; padding: 0; border: 0; border-radius: 4px; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.swatch::before { content: ""; width: 11px; height: 11px; border-radius: 3px; background: var(--level-0); }
.swatch[data-level="1"]::before { background: var(--level-1); }
.swatch[data-level="2"]::before { background: var(--level-2); }
.swatch[data-level="3"]::before { background: var(--level-3); }
.swatch[data-level="4"]::before { background: var(--level-4); }
.swatch[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px var(--foreground); }
/* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */
.tip { position: absolute; z-index: 5; top: 0; left: 0; width: 0; height: 0; pointer-events: none; }
.bubble { position: absolute; bottom: 8px; left: 0; display: block; padding: 7px 12px 8px; border: 1px solid var(--foreground); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); transform-origin: 50% 100%; will-change: transform, opacity; }
.tipBody { position: relative; display: block; overflow: clip; overflow-clip-margin: 8px; }
.tipMeasure { position: absolute; top: 0; left: 0; display: grid; width: max-content; visibility: hidden; }
.tipLines { display: grid; width: max-content; }
.tipMeasure > span, .tipLines > span { white-space: nowrap; }
.tipPrimary { font-size: var(--text-sm); font-weight: 500; line-height: 1.3; font-variant-numeric: tabular-nums; }
.tipSecondary { color: color-mix(in oklab, var(--background) 68%, var(--foreground)); font-size: var(--text-xs); line-height: 1.35; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cell, .grid[data-reveal="revealing"] .cell { transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.grid[data-reveal="hidden"] .cell { transform: none; }
.cell[data-empty], .grid[data-reveal="hidden"] .cell[data-empty] { opacity: 0; transform: none; }
.month, .ring { transition: opacity var(--duration-fast) var(--ease-standard); }
}
activity-heatmap.tsxactivity-heatmap.tsxactivity-heatmap.tsxtsx
"use client";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./activity-heatmap.module.css";
export interface ActivityDay {
/** Calendar day as YYYY-MM-DD. */
date: string;
count: number;
}
/**
* A contribution style calendar: one square per day, weeks as columns, with four accent tints for how busy a day was.
* Use it when rhythm, streaks, and quiet weeks matter more than exact comparisons; use a bar chart when the exact
* comparison is the point. Cells wave in once on view, a tooltip glides between cells on hover or keyboard focus, and a
* new range recolors the grid in a sweep instead of redrawing it. Arrow keys move by day and week, Enter selects.
*/
export interface ActivityHeatmapProps {
/** One entry per day, oldest first. Days missing inside the range count as zero. */
days: ActivityDay[];
/** Accessible name for the grid, such as "2025년 기여". */
label: string;
/** Names the range in the summary line: "{period} 동안 1,284 기여". */
period: string;
/** Nouns for the count. */
unit?: { one: string; other: string };
/** Upper bounds for levels one to three; anything above the last is level four. Defaults to quarters of the busiest day. */
thresholds?: [number, number, number];
weekStartsOn?: 0 | 1;
selectedDate?: string | null;
onSelectDate?: (date: string) => void;
/** Controls beside the summary, such as a range switch. */
actions?: ReactNode;
/** Formatting locale. Fixed by default so server and client render the same labels. */
locale?: string;
className?: string;
}
type Model = {
start: number; length: number; lead: number; weeks: number; total: number;
counts: number[]; levels: number[]; thresholds: [number, number, number];
months: { month: number; col: number; label: string }[];
/** How many days in the range sit at each level. */
perLevel: number[];
};
type Tip = { key: string; primary: string; secondary: string; value: number; anchor: HTMLElement };
/** How the tooltip text changes: rolls up or down with the count, crossfades on a tie, or appears at once when the bubble opens. */
type Change = 1 | -1 | 0 | "instant";
const DAY = 86_400_000;
const LEVELS = [0, 1, 2, 3, 4] as const;
const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
/** Total wave travel in ms, kept under the system's stagger budget however many weeks the range spans. */
const WAVE = 420;
const toUtc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);
const toIso = (time: number) => new Date(time).toISOString().slice(0, 10);
function buildModel(days: ActivityDay[], weekStartsOn: 0 | 1, thresholds: [number, number, number] | undefined, locale: string): Model {
const dates = days.map(day => toUtc(day.date)).filter(Number.isFinite);
const start = dates.length ? Math.min(...dates) : toUtc("2025-01-01");
const length = dates.length ? Math.round((Math.max(...dates) - start) / DAY) + 1 : 0;
const counts = new Array<number>(length).fill(0);
days.forEach(day => { const index = Math.round((toUtc(day.date) - start) / DAY); if (index >= 0 && index < length) counts[index]! += Math.max(0, day.count); });
const max = counts.reduce((a, b) => Math.max(a, b), 0);
const bounds = thresholds ?? [Math.max(1, Math.ceil(max * .25)), Math.max(2, Math.ceil(max * .5)), Math.max(3, Math.ceil(max * .75))] as [number, number, number];
const levels = counts.map(count => count <= 0 ? 0 : count <= bounds[0] ? 1 : count <= bounds[1] ? 2 : count <= bounds[2] ? 3 : 4);
const lead = (new Date(start).getUTCDay() - weekStartsOn + 7) % 7;
const weeks = Math.ceil((lead + length) / 7);
const monthName = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const months: Model["months"] = [];
for (let index = 0; index < length; index++) {
const date = new Date(start + index * DAY);
if (index === 0 || date.getUTCDate() === 1) months.push({ month: date.getUTCFullYear() * 12 + date.getUTCMonth(), col: Math.floor((lead + index) / 7), label: monthName.format(date) });
}
// A partial first month keeps its label only when there is room before the next one.
if (months.length > 1 && months[1]!.col - months[0]!.col < 3) months.shift();
const perLevel = [0, 0, 0, 0, 0];
levels.forEach(level => perLevel[level]!++);
return { start, length, lead, weeks, total: counts.reduce((a, b) => a + b, 0), counts, levels, thresholds: bounds, months, perLevel };
}
const CONTRIBUTIONS = { one: "기여", other: "기여" };
const noun = (count: number, unit: { one: string; other: string }) => count === 1 ? unit.one : unit.other;
const noopSubscribe = () => () => {};
/** Reduced motion only after hydration, so the server and the first client render agree. */
function useReducedMotionSafe() {
const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && !!reduced;
}
const tipText = (reduced: boolean): Variants => ({
from: (change: Change) => change === "instant" ? { opacity: 1, y: "0em", filter: "blur(0px)" } : reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * (change || 1)}em`, filter: `blur(${change ? motionTokens.blur.soft : motionTokens.blur.subtle}px)` },
to: { opacity: 1, y: "0em", filter: "blur(0px)" },
gone: (change: Change) => change === "instant" || reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * (change || 1)}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
});
/** A total that rolls digit by digit in the direction it moved. Places keep their identity, so only changed digits turn,
* and when the number gains or loses a digit the width follows on a spring instead of shifting the sentence in one frame. */
function RollingNumber({ value, locale, reduced }: { value: number; locale: string; reduced: boolean }) {
const [state, setState] = useState({ value, direction: 1 });
if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });
const inner = useRef<HTMLSpanElement>(null);
const width = useMotionValue<number | "auto">("auto");
const armedUntil = useRef(0), lastValue = useRef(value);
useLayoutEffect(() => {
if (lastValue.current === value) return;
lastValue.current = value;
armedUntil.current = performance.now() + 600;
}, [value]);
useEffect(() => {
const node = inner.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const next = node.getBoundingClientRect().width;
if (reduced || width.get() === "auto" || performance.now() > armedUntil.current) width.jump(next);
else animate(width, next, motionTokens.spring.morph);
});
observer.observe(node);
return () => observer.disconnect();
}, [reduced, width]);
const chars = [...new Intl.NumberFormat(locale).format(value)];
const variants: Variants = {
from: (direction: number) => reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` },
to: { opacity: 1, y: "0em", filter: "blur(0px)" },
gone: (direction: number) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
};
return <motion.span className={styles.rollingFrame} style={{ width }} aria-hidden="true"><span ref={inner} className={styles.rolling}>
{chars.map((char, index) => {
const place = chars.length - index;
return <span key={place} className={styles.place}>
<AnimatePresence mode="popLayout" initial={false} custom={state.direction}>
<motion.span key={char} className={styles.placeChar} custom={state.direction} variants={variants} initial="from" animate="to" exit="gone"
transition={reduced ? { duration: motionTokens.duration.fast } : { duration: motionTokens.duration.standard, ease: enter, delay: Math.min(place * .018, .09) }}>{char}</motion.span>
</AnimatePresence>
</span>;
})}
</span></motion.span>;
}
/** A short word that changes rises in from a soft blur while the old one lifts away a little faster. */
function RiseText({ text, reduced, children }: { text: string; reduced: boolean; children?: ReactNode }) {
return <span className={styles.rise} aria-hidden="true">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} className={styles.riseLine} initial={reduced ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: "0em", filter: "blur(0px)" }}
exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } }}
transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>{children ?? text}</motion.span>
</AnimatePresence>
</span>;
}
export function ActivityHeatmap({ days, label, period, unit: unitProp = CONTRIBUTIONS, thresholds, weekStartsOn = 0, selectedDate = null, onSelectDate, actions, locale = "ko-KR", className }: ActivityHeatmapProps) {
const reduced = useReducedMotionSafe();
const id = useId();
// Keyed by its words, so an inline unit object does not rebuild every cell on each render.
const unit = useMemo(() => ({ one: unitProp.one, other: unitProp.other }), [unitProp.one, unitProp.other]);
const rootRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const inView = useInView(plotRef, { once: true, amount: .35 });
const model = useMemo(() => buildModel(days, weekStartsOn, thresholds, locale), [days, weekStartsOn, thresholds, locale]);
const formats = useMemo(() => ({
long: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }),
short: new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }),
weekday: new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }),
number: new Intl.NumberFormat(locale),
}), [locale]);
// A new range sweeps forward when it is later than the last one and back when it is earlier.
const [range, setRange] = useState({ start: model.start, direction: 1 });
if (range.start !== model.start) setRange({ start: model.start, direction: model.start > range.start ? 1 : -1 });
const [reveal, setReveal] = useState<"hidden" | "revealing" | "done">("hidden");
useEffect(() => {
if (reveal !== "hidden" || !(inView || reduced)) return;
const frame = requestAnimationFrame(() => setReveal(reduced ? "done" : "revealing"));
return () => cancelAnimationFrame(frame);
}, [inView, reduced, reveal]);
useEffect(() => {
if (reveal !== "revealing") return;
const timer = window.setTimeout(() => setReveal("done"), WAVE + 700);
return () => window.clearTimeout(timer);
}, [reveal]);
const selectedIndex = selectedDate ? Math.round((toUtc(selectedDate) - model.start) / DAY) : -1;
const hasSelection = selectedIndex >= 0 && selectedIndex < model.length;
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const tabIndexDay = focusIndex !== null && focusIndex < model.length ? focusIndex : hasSelection ? selectedIndex : model.length - 1;
// Legend: hovering or focusing a level previews it on the grid, clicking pins it. The caption says how many days sit at that level.
const [preview, setPreview] = useState<number | null>(null);
const [pinned, setPinned] = useState<number | null>(null);
const [legendFocus, setLegendFocus] = useState(0);
const previewTimer = useRef(0);
const highlight = preview ?? pinned;
const previewLevel = (level: number | null) => { window.clearTimeout(previewTimer.current); if (level !== null) setPreview(level); else previewTimer.current = window.setTimeout(() => setPreview(null), 90); };
useEffect(() => () => window.clearTimeout(previewTimer.current), []);
// Tooltip: one bubble for the whole grid. It jumps into place when it opens and glides on a spring between cells after that.
const [tip, setTip] = useState<Tip | null>(null);
const [open, setOpen] = useState(false);
const [change, setChange] = useState<Change>("instant");
const openRef = useRef(false);
const hideTimer = useRef(0);
const tipX = useMotionValue(0), tipY = useMotionValue(0), tipWidth = useMotionValue<number | "auto">("auto");
function contentFor(index: number, anchor: HTMLElement): Tip {
const count = model.counts[index]!;
const date = new Date(model.start + index * DAY);
return { key: `day-${toIso(date.getTime())}`, primary: count ? `${formats.number.format(count)} ${noun(count, unit)}` : `${unit.other} 없음`, secondary: formats.short.format(date), value: count, anchor };
}
const [a, b, c] = model.thresholds;
const ranges = [`${unit.other} 없음`, a === 1 ? `1 ${unit.one}` : `1~${a} ${unit.other}`, `${a + 1}~${b} ${unit.other}`, `${b + 1}~${c} ${unit.other}`, `${c + 1} ${unit.other} 이상`];
/** The range as a phrase that describes a day: "기여 없는 날", "1~3 기여인 날". */
const dayPhrase = (level: number) => level === 0 ? `${unit.other} 없는 날` : `${ranges[level]}인 날`;
const dayCount = highlight === null ? "" : `${formats.number.format(model.perLevel[highlight]!)}일`;
const caption = highlight === null ? "" : `${dayPhrase(highlight)} ${dayCount}`;
function show(next: Tip) {
window.clearTimeout(hideTimer.current);
setChange(tip && openRef.current ? Math.sign(next.value - tip.value) as Change : "instant");
setTip(next);
setOpen(true);
}
function hideSoon(delay = 110) {
window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => setOpen(false), delay);
}
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
useLayoutEffect(() => {
const root = rootRef.current, measure = measureRef.current;
if (!tip || !root || !measure) return;
const place = (glide: boolean) => {
const box = root.getBoundingClientRect(), cell = tip.anchor.getBoundingClientRect();
const width = Math.ceil(measure.getBoundingClientRect().width);
const half = width / 2 + 13;
const x = Math.min(Math.max(cell.left - box.left + cell.width / 2, half + 2), box.width - half - 2);
const y = cell.top - box.top;
if (glide) {
animate(tipX, x, motionTokens.spring.snappy);
animate(tipY, y, motionTokens.spring.snappy);
animate(tipWidth, width, motionTokens.spring.morph);
} else { tipX.jump(x); tipY.jump(y); tipWidth.jump(width); }
};
place(open && openRef.current && !reduced);
openRef.current = open;
if (!open) return;
// The grid scrolls sideways on narrow screens; the bubble stays pinned to its cell while it does.
const scroller = scrollerRef.current;
const follow = () => place(false);
scroller?.addEventListener("scroll", follow, { passive: true });
return () => scroller?.removeEventListener("scroll", follow);
}, [tip, open, reduced, tipX, tipY, tipWidth]);
const indexOf = (element: Element | null) => {
const cell = element?.closest<HTMLElement>("[data-index]");
return cell && gridRef.current?.contains(cell) ? { cell, index: Number(cell.dataset.index) } : null;
};
const focusDay = (index: number) => gridRef.current?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.focus();
function onGridPointerOver(event: PointerEvent<HTMLDivElement>) {
const hit = indexOf(event.target as Element);
if (hit) show(contentFor(hit.index, hit.cell));
}
function onGridPointerLeave(event: PointerEvent<HTMLDivElement>) {
// A keyboard user keeps their bubble on the focused day. A tap focuses its day too, so the bubble stays until focus moves on;
// where a tap does not focus, it lingers long enough to read.
const focused = indexOf(document.activeElement);
if (focused?.cell.matches(":focus-visible")) show(contentFor(focused.index, focused.cell));
else hideSoon(event.pointerType === "touch" ? 2400 : 110);
}
function onGridFocus(event: FocusEvent<HTMLDivElement>) {
const hit = indexOf(event.target);
if (!hit) return;
setFocusIndex(hit.index);
show(contentFor(hit.index, hit.cell));
}
function onGridBlur(event: FocusEvent<HTMLDivElement>) {
if (!gridRef.current?.contains(event.relatedTarget as Node | null)) hideSoon(0);
}
function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const hit = indexOf(event.target as Element);
if (!hit) return;
const moves: Record<string, number> = { ArrowUp: hit.index - 1, ArrowDown: hit.index + 1, ArrowLeft: hit.index - 7, ArrowRight: hit.index + 7, Home: 0, End: model.length - 1 };
if (event.key in moves) {
event.preventDefault();
focusDay(Math.min(Math.max(moves[event.key]!, 0), model.length - 1));
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectDate?.(toIso(model.start + hit.index * DAY));
} else if (event.key === "Escape" && open) {
event.preventDefault();
setOpen(false);
}
}
function onLegendKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const moves: Record<string, number> = { ArrowLeft: legendFocus - 1, ArrowRight: legendFocus + 1, Home: 0, End: 4 };
if (event.key === "Escape") { previewLevel(null); return; }
if (!(event.key in moves)) return;
event.preventDefault();
const next = Math.min(Math.max(moves[event.key]!, 0), 4);
rootRef.current?.querySelector<HTMLElement>(`[data-level-key="${next}"]`)?.focus();
}
// The selection ring glides between cells on CSS. When it first appears it should fade in where it lands, not fly in from its last spot.
const ringShown = hasSelection && reveal !== "hidden";
const ringWasShown = useRef(ringShown);
useLayoutEffect(() => {
const ring = ringRef.current;
if (ring && ringShown && !ringWasShown.current) {
ring.style.transitionProperty = "opacity";
void ring.offsetWidth;
ring.style.transitionProperty = "";
}
ringWasShown.current = ringShown;
}, [ringShown]);
const maxDiagonal = Math.max(1, model.weeks - 1 + 6);
const step = Math.min(12, WAVE / maxDiagonal);
const cells = useMemo(() => Array.from({ length: 7 }, (_, row) => <div key={row} role="row" className={styles.row}>
{Array.from({ length: model.weeks }, (_, col) => {
const index = col * 7 + row - model.lead;
const inRange = index >= 0 && index < model.length;
const wave = { "--wave": `${Math.round((col + row) * step)}ms`, "--wave-back": `${Math.round((maxDiagonal - col - row) * step)}ms` } as CSSProperties;
if (!inRange) return <span key={col} className={styles.cell} data-empty="" style={wave} aria-hidden="true" />;
const count = model.counts[index]!;
const date = new Date(model.start + index * DAY);
return <span key={col} role="gridcell" className={styles.cell} style={wave} data-index={index} data-level={model.levels[index]} tabIndex={index === tabIndexDay ? 0 : -1}
aria-selected={onSelectDate ? index === selectedIndex : undefined} aria-label={`${count ? formats.number.format(count) : "0"} ${noun(count, unit)}, ${formats.long.format(date)}`}
onClick={() => onSelectDate?.(toIso(date.getTime()))} />;
})}
</div>), [model, step, maxDiagonal, tabIndexDay, selectedIndex, formats, unit, onSelectDate]);
const weekdayLabels = useMemo(() => Array.from({ length: 7 }, (_, row) => {
const weekday = (row + weekStartsOn) % 7;
// January 4, 1970 was a Sunday.
return weekday % 2 === 1 ? formats.weekday.format(new Date((3 + weekday) * DAY)) : "";
}), [formats, weekStartsOn]);
const selectedCol = hasSelection ? Math.floor((selectedIndex + model.lead) / 7) : 0;
const selectedRow = hasSelection ? (selectedIndex + model.lead) % 7 : 0;
const summary = `${period} 동안 ${formats.number.format(model.total)} ${noun(model.total, unit)}`;
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")}>
<div className={styles.header}>
<p className={styles.summary}>
<span className={styles.period}><RiseText text={period} reduced={reduced} /> 동안</span>{" "}
<span className={styles.total}><RollingNumber value={model.total} locale={locale} reduced={reduced} /> {noun(model.total, unit)}</span>
<span className={styles.srOnly} role="status">{summary}</span>
</p>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
<div ref={scrollerRef} className={styles.scroller}>
<div className={styles.canvas} style={{ "--weeks": model.weeks } as CSSProperties}>
<div className={styles.months} aria-hidden="true">
{model.months.map((month, order) => <span key={`${month.month % 12}-${model.months.findIndex(item => item.month % 12 === month.month % 12) === order ? 0 : 1}`} className={styles.month} style={{ "--col": month.col } as CSSProperties}>{month.label}</span>)}
</div>
<div className={styles.weekdays} aria-hidden="true">{weekdayLabels.map((text, row) => <span key={row} className={styles.weekday}>{text}</span>)}</div>
<div ref={plotRef} className={styles.plot}>
<div ref={gridRef} role="grid" aria-label={label} aria-readonly="true" aria-describedby={`${id}-legend`} className={styles.grid}
data-reveal={reveal} data-direction={range.direction < 0 ? "back" : undefined} data-highlight={highlight ?? undefined}
onPointerOver={onGridPointerOver} onPointerLeave={onGridPointerLeave} onFocus={onGridFocus} onBlur={onGridBlur} onKeyDown={onGridKeyDown}>
{cells}
</div>
<span ref={ringRef} className={styles.ring} data-shown={ringShown || undefined} style={{ "--col": selectedCol, "--row": selectedRow } as CSSProperties} aria-hidden="true" />
</div>
</div>
</div>
<div className={styles.legend}>
<span id={`${id}-legend`} className={styles.srOnly}>칸이 진할수록 활동이 많은 날이에요. 단계: {ranges.join(", ")}.</span>
<span className={styles.caption} aria-live="polite">
<RiseText text={caption} reduced={reduced}>{caption && <><span className={styles.captionLong}>{caption}</span><span className={styles.captionShort}>{dayCount}</span></>}</RiseText>
<span className={styles.srOnly}>{caption}</span>
</span>
<span className={styles.scale}>
<span className={styles.legendText} aria-hidden="true">적음</span>
<div className={styles.swatches} role="group" aria-label="단계별로 날짜 강조하기" onKeyDown={onLegendKeyDown}>
{LEVELS.map(level => <button key={level} type="button" className={styles.swatch} data-level-key={level} data-level={level} tabIndex={level === legendFocus ? 0 : -1}
aria-pressed={pinned === level} aria-label={`${dayPhrase(level)} 강조하기`}
onClick={() => setPinned(current => current === level ? null : level)}
onPointerEnter={() => previewLevel(level)} onPointerLeave={() => previewLevel(null)}
onFocus={() => { setLegendFocus(level); previewLevel(level); }} onBlur={() => previewLevel(null)} />)}
</div>
<span className={styles.legendText} aria-hidden="true">많음</span>
</span>
</div>
<motion.div className={styles.tip} style={{ x: tipX, y: tipY }} aria-hidden="true">
<motion.div className={styles.bubble} style={{ x: "-50%" }} initial={false} animate={open && tip ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduced ? 1 : .96 }}
transition={reduced ? { duration: open ? motionTokens.duration.fast : motionTokens.duration.instant } : open ? { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } : { duration: motionTokens.duration.instant, ease: standard }}>
<motion.span className={styles.tipBody} style={{ width: tipWidth }}>
<span ref={measureRef} className={styles.tipMeasure}><span className={styles.tipPrimary}>{tip?.primary}</span><span className={styles.tipSecondary}>{tip?.secondary}</span></span>
<AnimatePresence mode="popLayout" initial={false} custom={change}>
{tip && <motion.span key={tip.key} className={styles.tipLines} custom={change} variants={tipText(reduced)} initial="from" animate="to" exit="gone" transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>
<span className={styles.tipPrimary}>{tip.primary}</span>
<span className={styles.tipSecondary}>{tip.secondary}</span>
</motion.span>}
</AnimatePresence>
</motion.span>
</motion.div>
</motion.div>
</div>;
}
export default ActivityHeatmap;