Pulse Calendar
NewInstallation
npx shadcn@latest add https://uibrik.com/r/pulse-calendar.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/pulse-calendar/pulse-calendar.tsx"use client"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import NumberFlow from "@number-flow/react"; import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Segmented, useWidth } from "../_charts/parts"; import { clamp } from "../_charts/scale"; import { accentVars, tint } from "../_charts/theme"; import { formatDelta, formatValue, type ValueFormat } from "../_charts/format"; import { seeded } from "../_charts/sample"; export const SAMPLE_START = "2024-01-01"; const SAMPLE_DAYS = 366 + 365 + 284; const DAY_MS = 86_400_000; // Monday to Sunday: busiest midweek, with a little weekend side-project hacking. const DOW_WEIGHT = [0.85, 1, 1.05, 1, 0.78, 0.3, 0.2]; // Time off as [year, month (0-based), first day, last day]. const TIME_OFF = [ [2024, 7, 12, 25], [2025, 2, 14, 17], [2025, 7, 11, 22], [2026, 1, 13, 15], [2026, 6, 27, 31], [2026, 7, 1, 7], ]; /** * Daily commits for a project that starts mid-2024 and grows. Activity clumps * into streaks and droughts, follows the working week and six-week release * cycles, goes quiet on holidays and breaks, and has a few heavy days. */ export function sampleActivity(days = SAMPLE_DAYS) { const rand = seeded(2026); const gauss = () => Math.sqrt(-2 * Math.log(1 - rand())) * Math.cos(2 * Math.PI * rand()); const t0 = Date.UTC(2024, 0, 1); const launch = Math.round((Date.UTC(2024, 5, 17) - t0) / DAY_MS); // A slow mood, so good weeks and slumps run together instead of flickering. const mood: number[] = []; let m = 0; for (let i = 0; i < days; i++) { m = 0.9 * m + 0.45 * gauss(); mood.push(m); } return Array.from({ length: days }, (_, i) => { const date = new Date(t0 + i * DAY_MS); const year = date.getUTCFullYear(); const month = date.getUTCMonth(); const dom = date.getUTCDate(); const dow = (date.getUTCDay() + 6) % 7; // Drawn for every day, so tuning one rule never reshuffles the rest. const roll = rand(); const size = Math.exp(0.7 * gauss() - 0.25); if (i < launch) return 0; const since = i - launch; const ramp = 1 - Math.exp(-since / 260); const kickoff = since < 21 ? 1.9 : 1; const growth = 1 + since / 1000; // Six-week cycles: a push in the last days, a spike on release day, a breather after. const phase = since % 42; const release = phase === 41 ? 2.6 : phase >= 38 ? 1.6 : phase === 0 ? 0.4 : 1; const newYear = (month === 11 && dom >= 24) || (month === 0 && dom <= 2); const holiday = (month === 6 && dom === 4) || (month === 10 && dow === 3 && dom >= 22 && dom <= 28); const quiet = newYear ? 0.08 : holiday ? 0.05 : 1; const away = TIME_OFF.some(([y, mo, a, b]) => y === year && mo === month && dom >= a && dom <= b) ? 0.04 : 1; const engagement = clamp(Math.exp(0.55 * mood[i]), 0.35, 1.9); const chance = clamp((0.34 + 0.56 * ramp) * DOW_WEIGHT[dow] * engagement * quiet * away, 0, 0.97); if (roll > chance) return 0; return clamp(Math.round(6 * (0.45 + 0.55 * ramp) * growth * kickoff * release * size * (0.7 + 0.3 * engagement)), 1, 60); }); } const SAMPLE = sampleActivity(); // Weekday names down the left, month names across the top. const GUTTER = 40; const HEAD = 20; const CELL_MAX = 26; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DAY_NAMES = [ [0, "Mon"], [2, "Wed"], [4, "Fri"], ] as const; /** Dot size per level as a share of its cell, empty first. */ const SIZE = [0.17, 0.32, 0.42, 0.47, 0.5]; /** Dot strength per level in percent, empty first. One colour, so a change fades rather than flips. */ const STRENGTH = [14, 30, 50, 75, 100]; const SPRING = { type: "spring", stiffness: 520, damping: 38 } as const; /** Seconds between neighbouring columns when a new year or filter sweeps in. */ const SWEEP = 0.011; const SWEEP_TIME = 1100; const FILTERS = [ { value: "all", label: "All" }, { value: "weekdays", label: "Weekdays" }, { value: "weekends", label: "Weekends" }, ] as const; type Filter = (typeof FILTERS)[number]["value"]; const utc = (start: string) => new Date(`${start}T00:00:00Z`); const addDays = (d: Date, n: number) => new Date(d.getTime() + n * DAY_MS); const longDate = (d: Date) => d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }); export type PulseCalendarProps = { /** Names what is plotted. */ title?: string; /** One value per day, oldest first, starting on `start`. May span several years. */ values?: number[]; /** ISO date of the first value, e.g. "2024-01-01". */ start?: string; /** What one unit is called in the readout: "commits", "orders". */ unit?: string; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** Dot colour at full strength. Empty uses the chart palette. */ accent?: string; /** Year shown first. Defaults to the latest year in the data. */ defaultYear?: number; /** Weekday and weekend filter in the card's corner. */ showFilter?: boolean; /** Called with the day's index in `values` under the cursor or keyboard, or null. */ onActiveChange?: (index: number | null) => void; /** Called when the year arrows change the year. */ onYearChange?: (year: number) => void; className?: string; }; export function PulseCalendar({ title = "Commits", values = SAMPLE, start = SAMPLE_START, unit = "commits", format = {}, accent = "", defaultYear, showFilter = true, onActiveChange, onYearChange, className, }: PulseCalendarProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const n = values.length; const first = utc(start); const firstMs = first.getTime(); const firstYear = first.getUTCFullYear(); const lastYear = n ? addDays(first, n - 1).getUTCFullYear() : firstYear; const [year, setYear] = useState(() => clamp(defaultYear ?? lastYear, firstYear, lastYear)); const [filter, setFilter] = useState<Filter>("all"); const [hover, setHover] = useState<number | null>(null); const [picked, setPicked] = useState<number | null>(null); const [keyboard, setKeyboard] = useState<number | null>(null); // Which way the current sweep runs (1 left to right, -1 back), 0 once it has settled. const [sweep, setSweep] = useState<1 | -1 | 0>(1); const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const play = (dir: 1 | -1) => { setSweep(dir); clearTimeout(timer.current); timer.current = setTimeout(() => setSweep(0), SWEEP_TIME); }; // The first sweep is already on (state starts at 1); this lets it settle. useEffect(() => { timer.current = setTimeout(() => setSweep(0), SWEEP_TIME); return () => clearTimeout(timer.current); }, []); const fmt = (v: number) => formatValue(v, format); const max = Math.max(1, ...values); const level = (v: number) => (v <= 0 ? 0 : Math.min(4, Math.ceil((v / max) * 4))); // Every day of the chosen year, laid out Monday first. Days the data doesn't reach stay empty. const { cells, cols, offset } = useMemo(() => { const jan1 = new Date(Date.UTC(year, 0, 1)); const count = Math.round((Date.UTC(year + 1, 0, 1) - jan1.getTime()) / DAY_MS); const shift = (jan1.getUTCDay() + 6) % 7; const base = Math.round((jan1.getTime() - firstMs) / DAY_MS); const cells = Array.from({ length: count }, (_, k) => { const slot = k + shift; const index = base + k; const row = slot % 7; return { k, index: index >= 0 && index < n ? index : -1, v: index >= 0 && index < n ? values[index] : null, col: Math.floor(slot / 7), row, date: addDays(jan1, k), }; }); return { cells, cols: Math.ceil((count + shift) / 7), offset: shift }; }, [year, values, n, firstMs]); const matches = (row: number) => filter === "all" || (filter === "weekdays" ? row < 5 : row >= 5); const counted = cells.filter((c) => c.v !== null && matches(c.row)); const total = counted.reduce((a, c) => a + (c.v ?? 0), 0); const mean = counted.length ? total / counted.length : 0; const busiest = counted.reduce<number | null>((b, c) => (b === null || (c.v ?? 0) > (cells[b].v ?? 0) ? c.k : b), null); const months = useMemo(() => { const out: { col: number; label: string }[] = []; let last = -1; for (const c of cells) { const m = c.date.getUTCMonth(); if (m === last || !(c.row === 0 || c.k === 0)) continue; last = m; // A short stub of January at the start would collide with February. if (c.k === 0 && c.row > 3) continue; out.push({ col: c.col, label: MONTHS[m] }); } return out; }, [cells]); const cell = clamp((width - GUTTER) / cols, 8, CELL_MAX); const gridWidth = GUTTER + cell * cols; const svgHeight = HEAD + 7 * cell; const centre = (col: number, row: number) => [GUTTER + col * cell + cell / 2, HEAD + row * cell + cell / 2] as const; const lead = hover ?? keyboard ?? picked; const shown = lead ?? busiest; // Only a day with a count makes its neighbours react; a future or filtered-out day is just read out. const origin = lead !== null && cells[lead].v !== null && matches(cells[lead].row) ? cells[lead] : null; const active = shown !== null ? cells[shown] : null; const rel = mean && active?.v != null ? active.v / mean - 1 : 0; const report = (k: number | null) => onActiveChange?.(k !== null && cells[k]?.index >= 0 ? cells[k].index : null); const dayAt = (e: PointerEvent<SVGSVGElement>) => { const rect = e.currentTarget.getBoundingClientRect(); const col = Math.floor((e.clientX - rect.left - GUTTER) / cell); const row = Math.floor((e.clientY - rect.top - HEAD) / cell); if (col < 0 || row < 0 || row > 6) return null; const k = col * 7 + row - offset; return k >= 0 && k < cells.length ? k : null; }; const move = (e: PointerEvent<SVGSVGElement>) => { const k = dayAt(e); setKeyboard(null); setHover(k); report(k); }; const goTo = (next: number) => { const to = clamp(next, firstYear, lastYear); if (to === year) return; play(to > year ? 1 : -1); setYear(to); setHover(null); setPicked(null); setKeyboard(null); onActiveChange?.(null); onYearChange?.(to); }; const onKeyDown = (e: KeyboardEvent) => { const step = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1 }[e.key]; if (step !== undefined) { e.preventDefault(); const next = clamp((keyboard ?? picked ?? busiest ?? 0) + step, 0, cells.length - 1); setKeyboard(next); report(next); } else if ((e.key === "Enter" || e.key === " ") && keyboard !== null) { e.preventDefault(); setPicked((p) => (p === keyboard ? null : keyboard)); } else if (e.key === "Escape") { setKeyboard(null); report(null); } }; const ring = keyboard !== null ? cells[keyboard] : picked !== null ? cells[picked] : null; const delayFor = (col: number) => (sweep === 0 ? 0 : (sweep > 0 ? col : cols - 1 - col) * SWEEP); const filterLabel = FILTERS.find((f) => f.value === filter)?.label.toLowerCase(); return ( <ChartCard title={title} value={<Headline value={total} format={format} />} meta={ <span className="text-xs text-muted-foreground"> {filter === "all" ? unit : `${unit} on ${filterLabel}`} in {year} </span> } aside={ <div className="flex flex-wrap items-center justify-end gap-2"> {showFilter && <Segmented options={FILTERS} value={filter} onChange={(f) => { setFilter(f); setHover(null); setPicked(null); setKeyboard(null); play(1); }} label="Days shown" />} <div role="group" aria-label="Year" className="flex items-center rounded-[10px] bg-foreground/5 p-0.5"> <button type="button" aria-label="Previous year" disabled={year <= firstYear} onClick={() => goTo(year - 1)} className="grid size-6 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors duration-150 hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-foreground/12" > <IconChevronLeft size={14} stroke={2.25} aria-hidden /> </button> <NumberFlow value={year} locales="en-US" format={{ useGrouping: false }} aria-live="polite" className="min-w-10 text-center text-xs font-medium tabular-nums text-foreground" /> <button type="button" aria-label="Next year" disabled={year >= lastYear} onClick={() => goTo(year + 1)} className="grid size-6 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors duration-150 hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20 disabled:pointer-events-none disabled:opacity-30 dark:hover:bg-foreground/12" > <IconChevronRight size={14} stroke={2.25} aria-hidden /> </button> </div> </div> } footer={ <div className="flex items-center justify-end gap-1.5 text-xs font-medium text-muted-foreground" aria-hidden> Less {STRENGTH.map((s) => ( <span key={s} className="size-2.5 rounded-full" style={{ background: tint("var(--series-1)", s) }} /> ))} More </div> } className={cn("max-w-4xl", className)} style={accentVars(accent)} > <div ref={ref}> <div role="group" aria-label={`${title} by day in ${year}. Left and right arrows move a week, up and down a day, Enter selects.`} tabIndex={0} className="rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ width: gridWidth }} onKeyDown={onKeyDown} onFocus={(e) => e.currentTarget.matches(":focus-visible") && setKeyboard((k) => k ?? picked ?? busiest)} onBlur={() => setKeyboard(null)} > {width > 0 && cells.length > 0 && ( <svg width={gridWidth} height={svgHeight} className="block cursor-pointer overflow-visible" aria-hidden onPointerMove={move} onPointerDown={(e) => { const k = dayAt(e); setHover(k); if (k !== null && cells[k].v !== null && matches(cells[k].row)) setPicked((p) => (p === k ? null : k)); }} onPointerLeave={() => { setHover(null); report(null); }} > {months.map((m) => ( <motion.text key={`${year}-${m.col}`} x={GUTTER + m.col * cell} y={11} className="fill-muted-foreground text-[13px] font-medium" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: reduce ? 0 : 0.3, delay: reduce ? 0 : delayFor(m.col) + 0.1 }} > {m.label} </motion.text> ))} {DAY_NAMES.map(([row, name]) => ( <text key={name} x={0} y={HEAD + row * cell + cell / 2} dy="0.32em" className="fill-muted-foreground text-[13px] font-medium"> {name} </text> ))} {cells.map((c) => { const [cx, cy] = centre(c.col, c.row); const gone = c.v === null; const off = !gone && !matches(c.row); const l = gone || off ? 0 : level(c.v ?? 0); const dc = origin ? c.col - origin.col : 0; const dr = origin ? c.row - origin.row : 0; const dist = Math.hypot(dc, dr); // Gaussian falloff: the day itself swells most, its neighbours ease away from it. const near = origin && !gone ? Math.exp(-(dist * dist) / 4.5) : 0; const push = dist ? (cell * 0.22 * near) / dist : 0; const r = cell * (gone ? 0.07 : off ? 0.1 : SIZE[l]) * (1 + 0.45 * near); return ( <motion.circle key={c.k} fill="var(--series-1)" initial={{ cx, cy, r: 0, fillOpacity: 0 }} animate={{ cx: cx + dc * push, cy: cy + dr * push, r, fillOpacity: (gone ? 8 : off ? 9 : STRENGTH[l]) / 100, }} transition={ reduce ? { duration: 0 } : sweep === 0 ? SPRING : { type: "spring", bounce: 0, duration: 0.55, delay: delayFor(c.col) } } /> ); })} {ring && ( <motion.circle fill="none" className="stroke-foreground" strokeWidth={1.5} initial={false} animate={{ cx: centre(ring.col, ring.row)[0], cy: centre(ring.col, ring.row)[1], r: cell * 0.5 + 1 }} transition={reduce ? { duration: 0 } : SPRING} /> )} </svg> )} </div> {/* Always there, so pointing at a day never moves the card. */} <div className="mt-3 flex h-8 items-center justify-between gap-3 border-t border-foreground/6 pt-3 text-xs"> <span className="font-medium text-foreground tabular-nums">{active ? longDate(active.date) : "No data"}</span> {active && active.v === null && lead !== null && <span className="text-muted-foreground">No data yet</span>} {active && active.v !== null && ( <span className="flex items-center gap-2 text-muted-foreground tabular-nums"> {lead !== null && mean > 0 && matches(active.row) && ( <span className={cn(rel >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-muted-foreground")}>{formatDelta(rel)} vs avg</span> )} <span className="font-medium text-foreground"> {fmt(active.v)} {unit} </span> {lead === null && <span>busiest day</span>} {lead !== null && !matches(active.row) && <span>filtered out</span>} </span> )} </div> </div> <Announce> {lead !== null && cells[lead] ? `${longDate(cells[lead].date)}: ${cells[lead].v !== null ? `${fmt(cells[lead].v as number)} ${unit}` : "no data yet"}` : ""} </Announce> <DataTable caption={`${title} in ${year}`} columns={["Day", unit]} rows={cells.filter((c) => c.v !== null).map((c) => [longDate(c.date), fmt(c.v as number)])} /> </ChartCard> ); }
Adds the files to
components/uibrik/pulse-calendar and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/pulse-calendar.Props
PulseCalendar- title
- Type string
- "Commits"
- Names what is plotted.
- unit
- Type string
- "commits"
- What one unit is called in the readout.
- showFilter
- Type boolean
- true
- Weekday and weekend filter in the card's corner.
- defaultYear
- Type number
- 2026
- Year shown first.
- accent
- Type string
- ""
- Dot colour at full strength. Empty uses the chart palette.
- values
- Type number[]
- –
- One value per day, oldest first. May span several years.
- start
- Type string
- –
- ISO date of the first value, e.g. "2024-01-01".
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number.
- onActiveChange
- Type (index: number | null) => void
- –
- Called with the day's index in `values` under the cursor or keyboard.
- onYearChange
- Type (year: number) => void
- –
- Called when the year arrows change the year.
- className
- Type string
- –
- Classes for the outer shell.