{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"activity-heatmap","type":"registry:ui","title":"Activity heatmap","description":"See a year of activity at a glance, one square per day.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/activity-heatmap/activity-heatmap.tsx","type":"registry:component","target":"@components/arc/activity-heatmap/activity-heatmap.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./activity-heatmap.module.css\";\n\nexport interface ActivityDay {\n /** Calendar day as YYYY-MM-DD. */\n date: string;\n count: number;\n}\n\n/**\n * A contribution style calendar: one square per day, weeks as columns, with four accent tints for how busy a day was.\n * Use it when rhythm, streaks, and quiet weeks matter more than exact comparisons; use a bar chart when the exact\n * comparison is the point. Cells wave in once on view, a tooltip glides between cells on hover or keyboard focus, and a\n * new range recolors the grid in a sweep instead of redrawing it. Arrow keys move by day and week, Enter selects.\n */\nexport interface ActivityHeatmapProps {\n /** One entry per day, oldest first. Days missing inside the range count as zero. */\n days: ActivityDay[];\n /** Accessible name for the grid, such as \"Contributions in 2025\". */\n label: string;\n /** Finishes the summary line: \"1,284 contributions in {period}\". */\n period: string;\n /** Nouns for the count. */\n unit?: { one: string; other: string };\n /** Upper bounds for levels one to three; anything above the last is level four. Defaults to quarters of the busiest day. */\n thresholds?: [number, number, number];\n weekStartsOn?: 0 | 1;\n selectedDate?: string | null;\n onSelectDate?: (date: string) => void;\n /** Controls beside the summary, such as a range switch. */\n actions?: ReactNode;\n /** Formatting locale. Fixed by default so server and client render the same labels. */\n locale?: string;\n className?: string;\n}\n\ntype Model = {\n start: number; length: number; lead: number; weeks: number; total: number;\n counts: number[]; levels: number[]; thresholds: [number, number, number];\n months: { month: number; col: number; label: string }[];\n /** How many days in the range sit at each level. */\n perLevel: number[];\n};\ntype Tip = { key: string; primary: string; secondary: string; value: number; anchor: HTMLElement };\n/** How the tooltip text changes: rolls up or down with the count, crossfades on a tie, or appears at once when the bubble opens. */\ntype Change = 1 | -1 | 0 | \"instant\";\n\nconst DAY = 86_400_000;\nconst LEVELS = [0, 1, 2, 3, 4] as const;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n/** Total wave travel in ms, kept under the system's stagger budget however many weeks the range spans. */\nconst WAVE = 420;\nconst toUtc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);\nconst toIso = (time: number) => new Date(time).toISOString().slice(0, 10);\n\nfunction buildModel(days: ActivityDay[], weekStartsOn: 0 | 1, thresholds: [number, number, number] | undefined, locale: string): Model {\n const dates = days.map(day => toUtc(day.date)).filter(Number.isFinite);\n const start = dates.length ? Math.min(...dates) : toUtc(\"2025-01-01\");\n const length = dates.length ? Math.round((Math.max(...dates) - start) / DAY) + 1 : 0;\n const counts = new Array(length).fill(0);\n days.forEach(day => { const index = Math.round((toUtc(day.date) - start) / DAY); if (index >= 0 && index < length) counts[index] += Math.max(0, day.count); });\n const max = counts.reduce((a, b) => Math.max(a, b), 0);\n 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];\n const levels = counts.map(count => count <= 0 ? 0 : count <= bounds[0] ? 1 : count <= bounds[1] ? 2 : count <= bounds[2] ? 3 : 4);\n const lead = (new Date(start).getUTCDay() - weekStartsOn + 7) % 7;\n const weeks = Math.ceil((lead + length) / 7);\n const monthName = new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" });\n const months: Model[\"months\"] = [];\n for (let index = 0; index < length; index++) {\n const date = new Date(start + index * DAY);\n if (index === 0 || date.getUTCDate() === 1) months.push({ month: date.getUTCFullYear() * 12 + date.getUTCMonth(), col: Math.floor((lead + index) / 7), label: monthName.format(date) });\n }\n // A partial first month keeps its label only when there is room before the next one.\n if (months.length > 1 && months[1].col - months[0].col < 3) months.shift();\n const perLevel = [0, 0, 0, 0, 0];\n levels.forEach(level => perLevel[level]++);\n return { start, length, lead, weeks, total: counts.reduce((a, b) => a + b, 0), counts, levels, thresholds: bounds, months, perLevel };\n}\n\nconst CONTRIBUTIONS = { one: \"contribution\", other: \"contributions\" };\nconst noun = (count: number, unit: { one: string; other: string }) => count === 1 ? unit.one : unit.other;\nconst noopSubscribe = () => () => {};\n/** Reduced motion only after hydration, so the server and the first client render agree. */\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);\n const reduced = useReducedMotion();\n return hydrated && !!reduced;\n}\n\nconst tipText = (reduced: boolean): Variants => ({\n 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)` },\n to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n 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: .12, ease: standard } },\n});\n\n/** A total that rolls digit by digit in the direction it moved. Places keep their identity, so only changed digits turn,\n * and when the number gains or loses a digit the width follows on a spring instead of shifting the sentence in one frame. */\nfunction RollingNumber({ value, locale, reduced }: { value: number; locale: string; reduced: boolean }) {\n const [state, setState] = useState({ value, direction: 1 });\n if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });\n const inner = useRef(null);\n const width = useMotionValue(\"auto\");\n const armedUntil = useRef(0), lastValue = useRef(value);\n useLayoutEffect(() => {\n if (lastValue.current === value) return;\n lastValue.current = value;\n armedUntil.current = performance.now() + 600;\n }, [value]);\n useEffect(() => {\n const node = inner.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const next = node.getBoundingClientRect().width;\n if (reduced || width.get() === \"auto\" || performance.now() > armedUntil.current) width.jump(next);\n else animate(width, next, motionTokens.spring.morph);\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, [reduced, width]);\n const chars = [...new Intl.NumberFormat(locale).format(value)];\n const variants: Variants = {\n from: (direction: number) => reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` },\n to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n gone: (direction: number) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: .14, ease: standard } },\n };\n return \n {chars.map((char, index) => {\n const place = chars.length - index;\n return \n \n {char}\n \n ;\n })}\n ;\n}\n\n/** A short word that changes rises in from a soft blur while the old one lifts away a little faster. */\nfunction RiseText({ text, reduced, children }: { text: string; reduced: boolean; children?: ReactNode }) {\n return \n \n {children ?? text}\n \n ;\n}\n\nexport function ActivityHeatmap({ days, label, period, unit: unitProp = CONTRIBUTIONS, thresholds, weekStartsOn = 0, selectedDate = null, onSelectDate, actions, locale = \"en-US\", className }: ActivityHeatmapProps) {\n const reduced = useReducedMotionSafe();\n const id = useId();\n // Keyed by its words, so an inline unit object does not rebuild every cell on each render.\n const unit = useMemo(() => ({ one: unitProp.one, other: unitProp.other }), [unitProp.one, unitProp.other]);\n const rootRef = useRef(null);\n const plotRef = useRef(null);\n const gridRef = useRef(null);\n const scrollerRef = useRef(null);\n const ringRef = useRef(null);\n const measureRef = useRef(null);\n const inView = useInView(plotRef, { once: true, amount: .35 });\n\n const model = useMemo(() => buildModel(days, weekStartsOn, thresholds, locale), [days, weekStartsOn, thresholds, locale]);\n const formats = useMemo(() => ({\n long: new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }),\n short: new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }),\n weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }),\n number: new Intl.NumberFormat(locale),\n }), [locale]);\n\n // A new range sweeps forward when it is later than the last one and back when it is earlier.\n const [range, setRange] = useState({ start: model.start, direction: 1 });\n if (range.start !== model.start) setRange({ start: model.start, direction: model.start > range.start ? 1 : -1 });\n\n const [reveal, setReveal] = useState<\"hidden\" | \"revealing\" | \"done\">(\"hidden\");\n useEffect(() => {\n if (reveal !== \"hidden\" || !(inView || reduced)) return;\n const frame = requestAnimationFrame(() => setReveal(reduced ? \"done\" : \"revealing\"));\n return () => cancelAnimationFrame(frame);\n }, [inView, reduced, reveal]);\n useEffect(() => {\n if (reveal !== \"revealing\") return;\n const timer = window.setTimeout(() => setReveal(\"done\"), WAVE + 700);\n return () => window.clearTimeout(timer);\n }, [reveal]);\n\n const selectedIndex = selectedDate ? Math.round((toUtc(selectedDate) - model.start) / DAY) : -1;\n const hasSelection = selectedIndex >= 0 && selectedIndex < model.length;\n const [focusIndex, setFocusIndex] = useState(null);\n const tabIndexDay = focusIndex !== null && focusIndex < model.length ? focusIndex : hasSelection ? selectedIndex : model.length - 1;\n\n // Legend: hovering or focusing a level previews it on the grid, clicking pins it. The caption says how many days sit at that level.\n const [preview, setPreview] = useState(null);\n const [pinned, setPinned] = useState(null);\n const [legendFocus, setLegendFocus] = useState(0);\n const previewTimer = useRef(0);\n const highlight = preview ?? pinned;\n const previewLevel = (level: number | null) => { window.clearTimeout(previewTimer.current); if (level !== null) setPreview(level); else previewTimer.current = window.setTimeout(() => setPreview(null), 90); };\n useEffect(() => () => window.clearTimeout(previewTimer.current), []);\n\n // Tooltip: one bubble for the whole grid. It jumps into place when it opens and glides on a spring between cells after that.\n const [tip, setTip] = useState(null);\n const [open, setOpen] = useState(false);\n const [change, setChange] = useState(\"instant\");\n const openRef = useRef(false);\n const hideTimer = useRef(0);\n const tipX = useMotionValue(0), tipY = useMotionValue(0), tipWidth = useMotionValue(\"auto\");\n\n function contentFor(index: number, anchor: HTMLElement): Tip {\n const count = model.counts[index];\n const date = new Date(model.start + index * DAY);\n return { key: `day-${toIso(date.getTime())}`, primary: count ? `${formats.number.format(count)} ${noun(count, unit)}` : `No ${unit.other}`, secondary: formats.short.format(date), value: count, anchor };\n }\n const [a, b, c] = model.thresholds;\n const ranges = [`no ${unit.other}`, a === 1 ? `1 ${unit.one}` : `1 to ${a} ${unit.other}`, `${a + 1} to ${b} ${unit.other}`, `${b + 1} to ${c} ${unit.other}`, `${c + 1} or more ${unit.other}`];\n const shortRanges = [`no ${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}`];\n const dayCount = highlight === null ? \"\" : `${formats.number.format(model.perLevel[highlight])} ${model.perLevel[highlight] === 1 ? \"day\" : \"days\"}`;\n const caption = highlight === null ? \"\" : `${dayCount} with ${shortRanges[highlight]}`;\n function show(next: Tip) {\n window.clearTimeout(hideTimer.current);\n setChange(tip && openRef.current ? Math.sign(next.value - tip.value) as Change : \"instant\");\n setTip(next);\n setOpen(true);\n }\n function hideSoon(delay = 110) {\n window.clearTimeout(hideTimer.current);\n hideTimer.current = window.setTimeout(() => setOpen(false), delay);\n }\n useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n useLayoutEffect(() => {\n const root = rootRef.current, measure = measureRef.current;\n if (!tip || !root || !measure) return;\n const place = (glide: boolean) => {\n const box = root.getBoundingClientRect(), cell = tip.anchor.getBoundingClientRect();\n const width = Math.ceil(measure.getBoundingClientRect().width);\n const half = width / 2 + 13;\n const x = Math.min(Math.max(cell.left - box.left + cell.width / 2, half + 2), box.width - half - 2);\n const y = cell.top - box.top;\n if (glide) {\n animate(tipX, x, motionTokens.spring.snappy);\n animate(tipY, y, motionTokens.spring.snappy);\n animate(tipWidth, width, motionTokens.spring.morph);\n } else { tipX.jump(x); tipY.jump(y); tipWidth.jump(width); }\n };\n place(open && openRef.current && !reduced);\n openRef.current = open;\n if (!open) return;\n // The grid scrolls sideways on narrow screens; the bubble stays pinned to its cell while it does.\n const scroller = scrollerRef.current;\n const follow = () => place(false);\n scroller?.addEventListener(\"scroll\", follow, { passive: true });\n return () => scroller?.removeEventListener(\"scroll\", follow);\n }, [tip, open, reduced, tipX, tipY, tipWidth]);\n\n const indexOf = (element: Element | null) => {\n const cell = element?.closest(\"[data-index]\");\n return cell && gridRef.current?.contains(cell) ? { cell, index: Number(cell.dataset.index) } : null;\n };\n const focusDay = (index: number) => gridRef.current?.querySelector(`[data-index=\"${index}\"]`)?.focus();\n\n function onGridPointerOver(event: PointerEvent) {\n const hit = indexOf(event.target as Element);\n if (hit) show(contentFor(hit.index, hit.cell));\n }\n function onGridPointerLeave(event: PointerEvent) {\n // A keyboard user keeps their bubble on the focused day. A tap focuses its day too, so the bubble stays until focus moves on;\n // where a tap does not focus, it lingers long enough to read.\n const focused = indexOf(document.activeElement);\n if (focused?.cell.matches(\":focus-visible\")) show(contentFor(focused.index, focused.cell));\n else hideSoon(event.pointerType === \"touch\" ? 2400 : 110);\n }\n function onGridFocus(event: FocusEvent) {\n const hit = indexOf(event.target);\n if (!hit) return;\n setFocusIndex(hit.index);\n show(contentFor(hit.index, hit.cell));\n }\n function onGridBlur(event: FocusEvent) {\n if (!gridRef.current?.contains(event.relatedTarget as Node | null)) hideSoon(0);\n }\n function onGridKeyDown(event: KeyboardEvent) {\n const hit = indexOf(event.target as Element);\n if (!hit) return;\n const moves: Record = { ArrowUp: hit.index - 1, ArrowDown: hit.index + 1, ArrowLeft: hit.index - 7, ArrowRight: hit.index + 7, Home: 0, End: model.length - 1 };\n if (event.key in moves) {\n event.preventDefault();\n focusDay(Math.min(Math.max(moves[event.key], 0), model.length - 1));\n } else if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onSelectDate?.(toIso(model.start + hit.index * DAY));\n } else if (event.key === \"Escape\" && open) {\n event.preventDefault();\n setOpen(false);\n }\n }\n\n function onLegendKeyDown(event: KeyboardEvent) {\n const moves: Record = { ArrowLeft: legendFocus - 1, ArrowRight: legendFocus + 1, Home: 0, End: 4 };\n if (event.key === \"Escape\") { previewLevel(null); return; }\n if (!(event.key in moves)) return;\n event.preventDefault();\n const next = Math.min(Math.max(moves[event.key], 0), 4);\n rootRef.current?.querySelector(`[data-level-key=\"${next}\"]`)?.focus();\n }\n\n // 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.\n const ringShown = hasSelection && reveal !== \"hidden\";\n const ringWasShown = useRef(ringShown);\n useLayoutEffect(() => {\n const ring = ringRef.current;\n if (ring && ringShown && !ringWasShown.current) {\n ring.style.transitionProperty = \"opacity\";\n void ring.offsetWidth;\n ring.style.transitionProperty = \"\";\n }\n ringWasShown.current = ringShown;\n }, [ringShown]);\n\n const maxDiagonal = Math.max(1, model.weeks - 1 + 6);\n const step = Math.min(12, WAVE / maxDiagonal);\n const cells = useMemo(() => Array.from({ length: 7 }, (_, row) =>
\n {Array.from({ length: model.weeks }, (_, col) => {\n const index = col * 7 + row - model.lead;\n const inRange = index >= 0 && index < model.length;\n const wave = { \"--wave\": `${Math.round((col + row) * step)}ms`, \"--wave-back\": `${Math.round((maxDiagonal - col - row) * step)}ms` } as CSSProperties;\n if (!inRange) return ;\n const count = model.counts[index];\n const date = new Date(model.start + index * DAY);\n return onSelectDate?.(toIso(date.getTime()))} />;\n })}\n
), [model, step, maxDiagonal, tabIndexDay, selectedIndex, formats, unit, onSelectDate]);\n\n const weekdayLabels = useMemo(() => Array.from({ length: 7 }, (_, row) => {\n const weekday = (row + weekStartsOn) % 7;\n // January 4, 1970 was a Sunday.\n return weekday % 2 === 1 ? formats.weekday.format(new Date((3 + weekday) * DAY)) : \"\";\n }), [formats, weekStartsOn]);\n\n const selectedCol = hasSelection ? Math.floor((selectedIndex + model.lead) / 7) : 0;\n const selectedRow = hasSelection ? (selectedIndex + model.lead) % 7 : 0;\n const summary = `${formats.number.format(model.total)} ${noun(model.total, unit)} in ${period}`;\n\n return
\n
\n

\n {noun(model.total, unit)}{\" \"}\n in \n {summary}\n

\n {actions &&
{actions}
}\n
\n\n
\n
\n
\n {model.months.map((month, order) => item.month % 12 === month.month % 12) === order ? 0 : 1}`} className={styles.month} style={{ \"--col\": month.col } as CSSProperties}>{month.label})}\n
\n
{weekdayLabels.map((text, row) => {text})}
\n
\n
\n {cells}\n
\n \n
\n
\n
\n\n
\n Darker squares mean more {unit.other}. Levels: {ranges.join(\", \")}.\n \n {caption && <>{caption}{dayCount}}\n {caption}\n \n \n Less\n
\n {LEVELS.map(level =>
\n More\n
\n
\n\n \n \n \n {tip?.primary}{tip?.secondary}\n \n {tip && \n {tip.primary}\n {tip.secondary}\n }\n \n \n \n \n
;\n}\n\nexport default ActivityHeatmap;\n"},{"path":"registry/components/activity-heatmap/activity-heatmap.module.css","type":"registry:component","target":"@components/arc/activity-heatmap/activity-heatmap.module.css","content":"/* Levels are tints of the accent over a neutral empty square, so the grid follows the selected accent and both themes. */\n.root { --level-0: color-mix(in oklch, var(--foreground) 6.5%, var(--surface)); --level-1: color-mix(in oklch, var(--accent) 26%, var(--level-0)); --level-2: color-mix(in oklch, var(--accent) 50%, var(--level-0)); --level-3: color-mix(in oklch, 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); }\n.header { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-4); }\n.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; }\n.total { color: var(--foreground); font-weight: 500; white-space: nowrap; }\n.period { white-space: nowrap; }\n.actions { display: flex; flex: none; align-items: center; }\n.rollingFrame { display: inline-flex; justify-content: flex-end; overflow: clip; overflow-clip-margin: .2em; }\n.rolling { display: inline-flex; flex: none; }\n/* Each place is its own window, so a turning digit clips at the line instead of brushing the next line of text. */\n.place { position: relative; display: inline-flex; overflow: clip; overflow-clip-margin: .15em; }\n.placeChar, .riseLine { display: inline-block; }\n.rise { position: relative; display: inline-flex; }\n\n/* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */\n.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; }\n.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); }\n.months { position: relative; grid-row: 1; grid-column: 2; height: 1rem; color: var(--text-muted); font-size: var(--text-xs); line-height: 1rem; }\n/* Month labels glide to their new week when a range starts on a different weekday. */\n.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); }\n/* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */\n.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); }\n.weekday { display: flex; height: var(--cell); align-items: center; white-space: nowrap; line-height: 1; }\n.plot { position: relative; grid-row: 2; grid-column: 2; }\n.grid { display: grid; gap: var(--gap); outline: none; }\n.row { display: flex; gap: var(--gap); }\n\n/* One square per day. The wave delay rides a custom property so a reveal and a recolor sweep the same diagonal. */\n.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 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity var(--duration-fast) var(--ease-standard); }\n.grid[data-direction=\"back\"] .cell { --delay: var(--wave-back); }\n.cell[data-level=\"1\"] { background: var(--level-1); }\n.cell[data-level=\"2\"] { background: var(--level-2); }\n.cell[data-level=\"3\"] { background: var(--level-3); }\n.cell[data-level=\"4\"] { background: var(--level-4); }\n/* Days outside the range shrink away in the sweep and grow back when a range needs them. */\n.cell[data-empty] { transform: scale(0); background: var(--level-0); }\n.grid[data-reveal=\"hidden\"] .cell { opacity: 0; transform: scale(.5); transition: none; }\n.grid[data-reveal=\"hidden\"] .cell[data-empty] { transform: scale(0); }\n.grid[data-reveal=\"revealing\"] .cell { transition: background-color 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity 360ms var(--ease-enter) var(--delay); }\n.grid[data-highlight] .cell:not([data-empty]) { opacity: .2; }\n.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; }\n@media (hover: hover) and (pointer: fine) { .cell:not([data-empty]):hover { outline-color: color-mix(in oklch, var(--foreground) 45%, transparent); } }\n.cell:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }\n.cell[aria-selected=\"true\"]:focus-visible { outline-color: transparent; }\n\n/* The selection ring glides between days, and follows its day when the range changes. */\n.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); }\n.ring[data-shown] { opacity: 1; }\n\n.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); }\n/* The caption names the previewed level; it sits apart from the scale so its changing width never nudges the swatches. */\n.caption { position: relative; min-width: 0; overflow: hidden; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; text-overflow: ellipsis; }\n.scale { display: flex; flex: none; align-items: center; gap: var(--space-2); }\n.captionShort { display: none; }\n/* Narrow cards keep the day count and let the highlighted squares say which level it is. */\n@container (max-width: 440px) { .captionLong { display: none; } .captionShort { display: inline; } }\n.legendText { line-height: 1; }\n.swatches { display: flex; }\n/* The visible square stays the size of a day while the button keeps a comfortable target around it. */\n.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; }\n.swatch::before { content: \"\"; width: 11px; height: 11px; border-radius: 3px; background: var(--level-0); }\n.swatch[data-level=\"1\"]::before { background: var(--level-1); }\n.swatch[data-level=\"2\"]::before { background: var(--level-2); }\n.swatch[data-level=\"3\"]::before { background: var(--level-3); }\n.swatch[data-level=\"4\"]::before { background: var(--level-4); }\n.swatch[aria-pressed=\"true\"] { box-shadow: inset 0 0 0 1.5px var(--foreground); }\n.swatch:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }\n\n/* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */\n.tip { position: absolute; z-index: 5; top: 0; left: 0; width: 0; height: 0; pointer-events: none; }\n.bubble { position: absolute; bottom: 8px; left: 0; display: block; padding: 7px 12px 8px; border: 1px solid color-mix(in oklch, var(--background) 14%, 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; }\n.tipBody { position: relative; display: block; overflow: clip; overflow-clip-margin: 8px; }\n.tipMeasure { position: absolute; top: 0; left: 0; display: grid; width: max-content; visibility: hidden; }\n.tipLines { display: grid; width: max-content; }\n.tipMeasure > span, .tipLines > span { white-space: nowrap; }\n.tipPrimary { font-size: var(--text-sm); font-weight: 500; line-height: 1.3; font-variant-numeric: tabular-nums; }\n.tipSecondary { color: color-mix(in oklch, var(--background) 68%, var(--foreground)); font-size: var(--text-xs); line-height: 1.35; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .cell, .grid[data-reveal=\"revealing\"] .cell { transition: background-color 150ms linear, opacity 150ms linear; }\n .grid[data-reveal=\"hidden\"] .cell { transform: none; }\n .cell[data-empty], .grid[data-reveal=\"hidden\"] .cell[data-empty] { opacity: 0; transform: none; }\n .month, .ring { transition: opacity 150ms linear; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/activity-heatmap","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/activity-heatmap","markdown":"https://uiarc.dev/components/activity-heatmap/markdown","tags":["data","chart","calendar"]}}