Streak Calendar
NewInstallation
npx shadcn@latest add https://uibrik.com/r/streak-calendar.jsonInstall the packages
npm install @number-flow/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/streak-calendar/streak-calendar.tsx"use client"; import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import NumberFlow from "@number-flow/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // ─── Geometry ───────────────────────────────────────────────────────────────── const CELL = 14; const GAP = 4; const STEP = CELL + GAP; const DAY_COL = 28; const MONTH_ROW = 20; // Horizontal padding around the grid: shell p-1.5 plus panel p-4, both sides. const CHROME_X = (6 + 16) * 2; // Fewest weeks shown on a narrow screen before the card stops shrinking. const MIN_WEEKS = 8; const RECORD_MS = 2600; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAY_MS = 86_400_000; // Accent strength for each activity level; 0 is drawn as an empty well. const LEVEL_MIX = [0, 28, 50, 74, 100]; // Small deterministic PRNG so the sample history is identical on server and client. function hash(n: number) { let t = (n + 0x6d2b79f5) | 0; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } /** Sample count for the day `daysAgo` before today. */ function sampleCount(daysAgo: number, streak: number, seed: number) { if (daysAgo === 0) return 0; const r = hash(daysAgo * 31 + seed); if (daysAgo <= streak) return 1 + Math.floor(r * 7); // The day the streak broke, so the current run reads as a run. if (daysAgo === streak + 1) return 0; return r < 0.32 ? 0 : 1 + Math.floor(hash(daysAgo * 17 + seed) * 7); } /** Current and longest runs of active days, oldest first. An empty today doesn't break the run yet. */ function runs(counts: number[]) { let run = 0; let longest = 0; counts.forEach((c, i) => { if (c > 0) longest = Math.max(longest, ++run); else if (i !== counts.length - 1) run = 0; }); return { current: run, longest }; } type Day = { date: Date; daysAgo: number; col: number; row: number }; export type StreakCalendarProps = { /** Most weeks of history to show; fewer fit on narrow screens. */ weeks?: number; /** Day the week starts on. */ weekStart?: "sunday" | "monday"; /** Days in a row already logged before today, for the sample data. */ streak?: number; /** Colour of logged days. */ accent?: string; /** Unit shown in the tooltip, e.g. "sessions". */ unit?: string; /** Seeds the sample history. */ seed?: number; /** Count per day, oldest first, ending today. Replaces the sample data. */ data?: number[]; /** Called when today is logged (true) or the log is undone (false). */ onLogChange?: (logged: boolean) => void; className?: string; }; export function StreakCalendar({ weeks = 20, weekStart = "sunday", streak = 12, accent = "#22c55e", unit = "sessions", seed = 7, data, onLogChange, className, }: StreakCalendarProps) { const uid = useId(); const reduce = !!useReducedMotion(); const [ref, bounds] = useMeasure(); const [logged, setLogged] = useState(false); const [record, setRecord] = useState(false); const recordTimer = useRef<ReturnType<typeof setTimeout>>(undefined); useEffect(() => () => clearTimeout(recordTimer.current), []); // Hovered or keyboard-focused day, by days before today. const [active, setActive] = useState<number | null>(null); const [focused, setFocused] = useState(false); const [keyboard, setKeyboard] = useState(false); // As many weeks as the container fits, up to `weeks`. const fullWidth = CHROME_X + DAY_COL + weeks * STEP - GAP; const shown = bounds.width ? Math.max(MIN_WEEKS, Math.min(weeks, Math.floor((bounds.width - CHROME_X - DAY_COL + GAP) / STEP))) : weeks; const start = weekStart === "monday" ? 1 : 0; const days = useMemo(() => { const today = new Date(); today.setHours(0, 0, 0, 0); const dow = (today.getDay() - start + 7) % 7; const total = (shown - 1) * 7 + dow + 1; const out: Day[] = []; for (let i = 0; i < total; i++) { const daysAgo = total - 1 - i; out.push({ date: new Date(today.getTime() - daysAgo * DAY_MS), daysAgo, col: Math.floor(i / 7), row: i % 7 }); } return out; }, [shown, start]); const total = days.length; const dayAt = (daysAgo: number) => days[total - 1 - daysAgo]; // Counts for the visible days, oldest first; today's slot reflects the log. const counts = useMemo( () => days.map((d) => { const base = data ? Math.max(0, data[data.length - 1 - d.daysAgo] ?? 0) : sampleCount(d.daysAgo, streak, seed); return d.daysAgo === 0 && logged ? base + 1 : base; }), [days, data, streak, seed, logged], ); const countOf = (daysAgo: number) => counts[total - 1 - daysAgo] ?? 0; const max = Math.max(1, ...counts); const levelOf = (daysAgo: number) => { const c = countOf(daysAgo); return c <= 0 ? 0 : Math.min(4, Math.ceil((c / max) * 4)); }; const { current, longest } = runs(counts); const months = useMemo(() => { const labels: { col: number; label: string }[] = []; let last = -1; for (const d of days) { if (d.row !== 0) continue; const m = d.date.getMonth(); if (m !== last) labels.push({ col: d.col, label: MONTHS[m] }); last = m; } // A partial month at the start would crowd the next label, so it gives way. return labels.filter((l, i) => !labels[i + 1] || labels[i + 1].col - l.col >= 3); }, [days]); const toggleLog = () => { const next = !logged; clearTimeout(recordTimer.current); if (next) { // Logging extends the current run by one; beating the longest is worth a beat. const before = runs(counts); if (before.current + 1 > before.longest) { setRecord(true); recordTimer.current = setTimeout(() => setRecord(false), RECORD_MS); } } else { setRecord(false); } setLogged(next); onLogChange?.(next); }; const onKeyDown = (e: KeyboardEvent) => { const at = active ?? 0; // Columns are weeks, so left and right step a week; up and down step a day. const moves: Record<string, number> = { ArrowLeft: 7, ArrowRight: -7, ArrowUp: 1, ArrowDown: -1 }; let next: number | null = null; if (e.key in moves) next = at + moves[e.key]; else if (e.key === "Home") next = total - 1; else if (e.key === "End") next = 0; else if ((e.key === "Enter" || e.key === " ") && at === 0) { e.preventDefault(); toggleLog(); return; } if (next === null) return; e.preventDefault(); setKeyboard(true); setActive(Math.max(0, Math.min(total - 1, next))); }; const fill = (level: number) => level === 0 ? "color-mix(in srgb, var(--foreground) 7%, transparent)" : `color-mix(in srgb, ${accent} ${LEVEL_MIX[level]}%, transparent)`; const gridW = shown * STEP - GAP; const gridH = 7 * STEP - GAP; const activeDay = active !== null ? dayAt(active) : undefined; const cellId = (daysAgo: number) => `${uid}-d${daysAgo}`; const lift = { type: "spring", stiffness: 600, damping: 32 } as const; return ( <div ref={ref} className={cn("w-full rounded-[22px] bg-muted p-1.5 edge-outer", className)} style={{ maxWidth: fullWidth }} > <div className="flex items-end justify-between gap-6 px-3.5 pb-3 pt-2"> <div> <div className="text-[13px] font-medium text-muted-foreground">Current streak</div> <div className="mt-0.5 flex items-baseline gap-1.5 text-foreground"> <NumberFlow value={current} className="text-3xl font-semibold tabular-nums tracking-tight" /> <span className="text-sm font-medium text-muted-foreground">{current === 1 ? "day" : "days"}</span> <AnimatePresence> {record && ( <motion.span className="ml-1 self-center rounded-full px-2 py-0.5 text-[11px] font-semibold text-foreground" style={{ background: `color-mix(in srgb, ${accent} 18%, transparent)` }} initial={{ opacity: 0, x: -4, filter: "blur(2px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} exit={{ opacity: 0, filter: "blur(2px)" }} transition={{ duration: reduce ? 0 : 0.25 }} > New record </motion.span> )} </AnimatePresence> </div> </div> <div className="pb-1 text-right text-[13px] text-muted-foreground"> Longest <NumberFlow value={longest} className="font-medium tabular-nums text-foreground" /> days </div> </div> <div className="rounded-2xl bg-background p-4 edge-inner"> <div className="relative" style={{ width: DAY_COL + gridW, height: MONTH_ROW + gridH }}> {months.map((m) => ( <span key={`${m.col}-${m.label}`} className="absolute top-0 text-[11px] font-medium text-muted-foreground" style={{ left: DAY_COL + m.col * STEP }} > {m.label} </span> ))} {[0, 1, 2, 3, 4, 5, 6] .filter((row) => [1, 3, 5].includes((row + start) % 7)) .map((row) => ( <span key={row} className="absolute left-0 text-[11px] font-medium leading-none text-muted-foreground" style={{ top: MONTH_ROW + row * STEP + (CELL - 11) / 2 }} > {WEEKDAYS[(row + start) % 7]} </span> ))} <div role="listbox" tabIndex={0} aria-label={`Activity for the last ${shown} weeks. Current streak ${current} days. Arrow keys move between days.`} aria-activedescendant={active !== null ? cellId(active) : undefined} className="absolute rounded-sm outline-none" style={{ left: DAY_COL, top: MONTH_ROW, width: gridW, height: gridH }} onFocus={() => { setFocused(true); if (active === null) setActive(0); }} onBlur={() => { setFocused(false); setKeyboard(false); setActive(null); }} onKeyDown={onKeyDown} onPointerMove={() => keyboard && setKeyboard(false)} onPointerLeave={() => !focused && setActive(null)} > {days.map((d) => { const isToday = d.daysAgo === 0; const level = levelOf(d.daysAgo); const isActive = active === d.daysAgo; return ( <motion.div key={d.daysAgo} id={cellId(d.daysAgo)} role="option" aria-selected={isActive} aria-label={describe(d, countOf(d.daysAgo), unit, logged)} className="absolute rounded-sm" style={{ left: d.col * STEP, top: d.row * STEP, width: CELL, height: CELL, background: isToday ? fill(0) : fill(level), zIndex: isActive ? 2 : 1, cursor: isToday ? "pointer" : undefined, }} animate={{ scale: isActive && !reduce ? 1.45 : 1, boxShadow: isActive && !reduce ? "0 4px 10px rgb(0 0 0 / 0.18)" : "0 0 0 rgb(0 0 0 / 0)", }} transition={reduce ? { duration: 0 } : lift} onPointerEnter={() => setActive(d.daysAgo)} onClick={isToday ? toggleLog : undefined} > {isToday && ( <> <span aria-hidden className="absolute -inset-0.75 rounded-md transition-shadow duration-200" style={{ boxShadow: `inset 0 0 0 1.5px ${logged ? accent : "var(--muted-foreground)"}` }} /> <span className="absolute inset-0 overflow-hidden rounded-sm"> <motion.span aria-hidden className="absolute inset-0 origin-bottom" style={{ background: fill(Math.max(1, level)) }} initial={false} animate={{ scaleY: logged ? 1 : 0 }} transition={{ duration: reduce ? 0 : 0.5, ease: [0.23, 1, 0.32, 1] }} /> </span> </> )} {/* Keyboard focus ring, drawn on the day itself. */} {isActive && keyboard && ( <span aria-hidden className="absolute -inset-0.5 rounded-[5px] ring-2 ring-foreground/40" /> )} </motion.div> ); })} </div> <AnimatePresence> {activeDay && ( <motion.div key="tip" aria-hidden className="pointer-events-none absolute z-10" style={{ left: 0, top: 0 }} initial={{ opacity: 0, x: DAY_COL + activeDay.col * STEP + CELL / 2, y: MONTH_ROW + activeDay.row * STEP - 4, }} animate={{ opacity: 1, x: DAY_COL + activeDay.col * STEP + CELL / 2, y: MONTH_ROW + activeDay.row * STEP - 4, }} exit={{ opacity: 0 }} transition={ reduce ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 45, opacity: { duration: 0.12 } } } > <div className={cn( "-translate-y-full whitespace-nowrap rounded-lg bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-lg", // Near the edges the tip leans inward instead of hanging off the card. activeDay.col < 3 ? "-translate-x-3" : activeDay.col > shown - 4 ? "-translate-x-[calc(100%-12px)]" : "-translate-x-1/2", )} > {tip(activeDay, countOf(activeDay.daysAgo), unit, logged)} </div> </motion.div> )} </AnimatePresence> </div> <div className="mt-4 flex items-center justify-between gap-4"> <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground"> Less {LEVEL_MIX.map((_, l) => ( <span key={l} className="size-2.5 rounded-[3px]" style={{ background: fill(l) }} /> ))} More </div> <LogButton logged={logged} reduce={reduce} onClick={toggleLog} /> </div> </div> </div> ); } /** "sessions" for 1 reads "session". */ const unitFor = (count: number, unit: string) => (count === 1 ? unit.replace(/s$/, "") : unit); const dateLabel = (d: Date) => `${WEEKDAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`; function describe(day: Day, count: number, unit: string, logged: boolean) { if (day.daysAgo === 0 && !logged) return "Today, not logged yet"; return `${day.daysAgo === 0 ? "Today" : dateLabel(day.date)}: ${count || "no"} ${unitFor(count, unit)}`; } function tip(day: Day, count: number, unit: string, logged: boolean) { if (day.daysAgo === 0 && !logged) return <>Today · not logged yet</>; return ( <> <span className="opacity-60">{day.daysAgo === 0 ? "Today" : dateLabel(day.date)}</span> · {count || "No"} {unitFor(count, unit)} </> ); } const EASE_OUT = [0.23, 1, 0.32, 1] as const; const BUTTON_PAD = 14; /** * "Log today" and "Undo" in one button. The fill fades from solid to tinted, * the width eases to fit the new label, and the labels slide past each other: * up when logging, back down when undoing. */ function LogButton({ logged, reduce, onClick }: { logged: boolean; reduce: boolean; onClick: () => void }) { const label = logged ? "Undo" : "Log today"; // An invisible copy of the current label sets the width the button eases to. const [sizer, bounds] = useMeasure(); const dir = logged ? 1 : -1; const shift = reduce ? 0 : 10; const duration = reduce ? 0 : 0.3; return ( <motion.button type="button" onClick={onClick} className="group relative h-8 shrink-0 cursor-pointer overflow-hidden rounded-full text-[13px] font-medium outline-none transition-transform duration-150 active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-foreground/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background" initial={false} animate={{ width: bounds.width ? bounds.width + BUTTON_PAD * 2 : logged ? 64 : 92 }} transition={{ duration, ease: EASE_OUT }} > <span aria-hidden className="absolute inset-0 bg-foreground/6 transition-colors duration-150 group-hover:bg-foreground/10" /> <motion.span aria-hidden className="absolute inset-0 bg-foreground" initial={false} animate={{ opacity: logged ? 0 : 1 }} transition={{ duration: reduce ? 0 : 0.25 }} /> <span ref={sizer} aria-hidden className="invisible absolute whitespace-nowrap"> {label} </span> <AnimatePresence initial={false} custom={dir}> <motion.span key={label} custom={dir} className={cn( "absolute inset-0 flex items-center justify-center whitespace-nowrap", logged ? "text-foreground" : "text-background", )} variants={{ enter: (d: number) => ({ y: shift * d, opacity: 0, filter: "blur(3px)" }), center: { y: 0, opacity: 1, filter: "blur(0px)" }, exit: (d: number) => ({ y: -shift * d, opacity: 0, filter: "blur(3px)" }), }} initial="enter" animate="center" exit="exit" transition={{ duration: reduce ? 0 : 0.22, ease: EASE_OUT }} > {label} </motion.span> </AnimatePresence> </motion.button> ); }
Adds the files to
components/uibrik/streak-calendar and installs @number-flow/react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/streak-calendar.Props
StreakCalendar- weeks
- Type number
- 20
- Most weeks of history to show; fewer fit on narrow screens.
- weekStart
- Type "sunday" | "monday"
- "sunday"
- Day the week starts on.
- streak
- Type number
- 12
- Days in a row already logged before today, for the sample data.
- accent
- Type string
- "#22c55e"
- Colour of logged days.
- unit
- Type string
- "sessions"
- Unit shown in the tooltip.
- seed
- Type number
- 7
- Seeds the sample history.
- data
- Type number[]
- –
- Count per day, oldest first, ending today. Replaces the sample data; shades are scaled to the busiest day.
- onLogChange
- Type (logged: boolean) => void
- –
- Called when today is logged, or the log is undone.
- className
- Type string
- –
- Classes for the outer shell.