Scrub Timeline
Installation
npx shadcn@latest add https://uibrik.com/r/scrub-timeline.jsonInstall the packages
npm install 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/scrub-timeline/scrub-timeline.tsx"use client"; import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // ─── Geometry ───────────────────────────────────────────────────────────────── const WEEKS = 52; const CARD_AREA = 92; const CARD_GAP = 18; // between the card's bottom and the ruler const RULER_H = 56; const TICK = 12; const TICK_MONTH = 18; const TICK_EVENT = 24; const SNAP = 3; // weeks around a milestone that pull the playhead in const DRAG_SLOP = 3; const PILL = { w: 64, h: 26 }; const CARD = { w: 220, h: 68 }; const NOTCH = { w: 12, h: 6 }; // Below this many px per month the labels thin out to quarters. const MONTH_LABEL_MIN = 34; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const SPRING = { type: "spring", stiffness: 520, damping: 42 } as const; const GLIDE = { type: "spring", stiffness: 600, damping: 45 } as const; const INSTANT = { duration: 0 } as const; export type TimelineEvent = { week: number; title: string; detail?: string }; const DEFAULT_EVENTS: TimelineEvent[] = [ { week: 3, title: "Kickoff", detail: "Four people, one goal" }, { week: 12, title: "Private beta", detail: "First 50 teams invited" }, { week: 22, title: "Public launch", detail: "Live on the web and iOS" }, { week: 34, title: "1,000 customers", detail: "Six weeks ahead of plan" }, { week: 34, title: "Team of 20", detail: "Hired across three cities" }, { week: 47, title: "Version 2", detail: "Rebuilt sync engine" }, ]; const weekDate = (year: number, week: number) => new Date(Date.UTC(year, 0, 1 + week * 7)); const formatDate = (d: Date) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); /** This week's index if `year` is the current year; past years are all done, future ones all ahead. */ function currentWeek(year: number) { const now = new Date(); if (now.getFullYear() < year) return -1; if (now.getFullYear() > year) return WEEKS; return Math.floor((Date.UTC(year, now.getMonth(), now.getDate()) - Date.UTC(year, 0, 1)) / (7 * 86_400_000)); } export type ScrubTimelineProps = { title?: string; /** Year the ruler spans, one tick per week. */ year?: number; events?: TimelineEvent[]; /** Colour of milestone ticks. */ accent?: string; /** How much ticks grow under the pointer; 0 turns it off. */ magnify?: number; /** Week the playhead starts on. */ defaultWeek?: number; /** Week to mark as today. Defaults to the real date; earlier milestones show as done. */ todayWeek?: number; onChange?: (week: number) => void; className?: string; }; export function ScrubTimeline({ title = "2026 roadmap", year = 2026, events = DEFAULT_EVENTS, accent = "#f97316", magnify = 1.6, defaultWeek = 22, todayWeek, onChange, className, }: ScrubTimelineProps) { const reduce = !!useReducedMotion(); const [ref, bounds] = useMeasure(); const width = bounds.width; const spacing = width / (WEEKS - 1); const today = useMemo(() => todayWeek ?? currentWeek(year), [todayWeek, year]); // Milestones grouped by week, so two in one week share a tick. const groups = useMemo(() => { const map = new Map<number, TimelineEvent[]>(); for (const e of events) map.set(e.week, [...(map.get(e.week) ?? []), e]); return map; }, [events]); const eventWeeks = useMemo(() => [...groups.keys()].sort((a, b) => a - b), [groups]); const [week, setWeekState] = useState(clamp(Math.round(defaultWeek), 0, WEEKS - 1)); const [dragging, setDragging] = useState(false); // Milestone under the pointer while hovering (not dragging); the card peeks at it. const [peek, setPeek] = useState<number | null>(null); const drag = useRef<{ startX: number; moved: boolean } | null>(null); const setWeek = (w: number) => { if (w === week) return; setWeekState(w); onChange?.(w); }; // Pointer position and how strongly it magnifies; the strength fades in and // out so ticks settle back instead of snapping when the pointer leaves. const mx = useMotionValue(-1e4); const strength = useMotionValue(0); const playX = useMotionValue(0); const placed = useRef(false); // At rest the playhead sits on its week; while dragging the pointer drives it. useEffect(() => { if (!width || dragging) return; const target = week * spacing; // First measure: place it without sliding in from 0. if (!placed.current) { playX.set(target); placed.current = true; return; } const a = animate(playX, target, reduce ? INSTANT : SPRING); return () => a.stop(); }, [week, spacing, width, dragging, playX, reduce]); /** * Near a milestone the playhead lags the pointer and then catches up, so it * sticks to the milestone and breaks free past it. Continuous at the edge of * the pull, so there's no jump going in or out. */ const magnet = (x: number) => { const reach = SNAP * spacing; let best: { at: number; d: number } | null = null; for (const w of eventWeeks) { const d = x - w * spacing; if (Math.abs(d) < reach && (!best || Math.abs(d) < Math.abs(best.d))) best = { at: w * spacing, d }; } if (!best || reduce) return clamp(x, 0, width); const t = Math.abs(best.d) / reach; return clamp(best.at + Math.sign(best.d) * reach * t ** 2.2, 0, width); }; const pointerX = (e: PointerEvent<HTMLElement>) => e.clientX - e.currentTarget.getBoundingClientRect().left; const toWeek = (x: number) => clamp(Math.round(x / spacing), 0, WEEKS - 1); const nearestEvent = (w: number, within: number) => eventWeeks.reduce<number | null>( (best, e) => (Math.abs(e - w) <= within && (best === null || Math.abs(e - w) < Math.abs(best - w)) ? e : best), null, ); const fadeOut = () => { animate(strength, 0, { duration: 0.3 }); setPeek(null); }; const release = (e: PointerEvent<HTMLElement>) => { const d = drag.current; if (!d) return; drag.current = null; // A plain click already picked its week on the way down; only a drag settles here. if (d.moved) { const snapped = nearestEvent(toWeek(playX.get()), SNAP); setWeek(snapped ?? toWeek(playX.get())); } setDragging(false); // Released off the ruler, or by a finger: nothing is hovering any more. const r = e.currentTarget.getBoundingClientRect(); const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom; if (outside || e.pointerType !== "mouse") fadeOut(); }; const step = (dir: 1 | -1, toEvent: boolean) => { if (!toEvent) return setWeek(clamp(week + dir, 0, WEEKS - 1)); const next = dir > 0 ? eventWeeks.find((w) => w > week) : [...eventWeeks].reverse().find((w) => w < week); if (next !== undefined) setWeek(next); }; // ─── Card ───────────────────────────────────────────────────────────────── const peeking = peek !== null && peek !== week && !dragging; const shownWeek = peeking ? peek : week; const group = groups.get(shownWeek); // Which milestone of a shared week is showing; resets when the week changes. const [sub, setSub] = useState(0); const [subWeek, setSubWeek] = useState(shownWeek); if (subWeek !== shownWeek) { setSubWeek(shownWeek); setSub(0); } const active = group?.[sub % group.length]; // Direction of travel, so the card's content slides the way you're moving. const position = shownWeek * 10 + sub; const [last, setLast] = useState({ position, dir: 1 }); if (last.position !== position) setLast({ position, dir: position > last.position ? 1 : -1 }); const dir = last.dir; const half = (active ? CARD.w : PILL.w) / 2; const targetX = shownWeek * spacing; const cardX = width ? clamp(targetX, half, width - half) : 0; // The notch stays under the playhead, but never past the card's rounded corners. const notchX = clamp(targetX, cardX - half + 16, cardX + half - 16); const status = (w: number) => (w < today ? "Done" : w === today ? "This week" : "Upcoming"); const cycle = () => group && group.length > 1 && setSub((s) => (s + 1) % group.length); const shift = reduce ? 0 : 10; const move = reduce ? INSTANT : SPRING; // ─── Labels ─────────────────────────────────────────────────────────────── const months = MONTHS.map((label, m) => ({ label, m, week: (Date.UTC(year, m, 1) - Date.UTC(year, 0, 1)) / (7 * 86_400_000), })); const monthStarts = new Set(months.map((m) => Math.round(m.week))); // Narrow screens: quarters only, so the labels never touch. const shownMonths = (WEEKS / 12) * spacing < MONTH_LABEL_MIN ? months.filter((m) => m.m % 3 === 0) : months; const onEvent = groups.has(week); const valueText = [ formatDate(weekDate(year, week)), week === today ? "today" : "", ...(groups.get(week) ?? []).map((e) => e.title), ] .filter(Boolean) .join(", "); return ( <div className={cn("w-full max-w-xl rounded-[22px] bg-muted p-1.5 edge-outer select-none", className)}> <div className="flex items-baseline justify-between px-3.5 pb-2.5 pt-2 text-sm"> <span className="font-medium text-muted-foreground">{title}</span> <span className="text-xs text-muted-foreground">{events.length} milestones</span> </div> <div className="rounded-2xl bg-background px-6 pb-4 pt-3 edge-inner"> <div ref={ref} className="relative" style={{ height: CARD_AREA + RULER_H + 22 }}> {width > 0 && ( <> {/* Card: a date pill that grows into the milestone when the playhead lands on one. */} <motion.div className="absolute left-0 z-10" style={{ top: CARD_AREA - CARD_GAP }} initial={false} animate={{ x: cardX, opacity: peeking ? 0.6 : 1 }} transition={{ ...move, opacity: { duration: 0.15 } }} > <motion.div className="pointer-events-none absolute bottom-0 left-0 -translate-x-1/2 overflow-hidden bg-muted edge-outer" initial={false} animate={{ width: active ? CARD.w : PILL.w, height: active ? CARD.h : PILL.h, borderRadius: active ? 16 : 13, }} transition={move} > <AnimatePresence initial={false} custom={dir}> <motion.div key={active ? `${shownWeek}-${sub}` : "date"} custom={dir} className={cn("absolute inset-0 flex", active ? "flex-col justify-center px-3.5" : "items-center justify-center")} variants={{ enter: (d: number) => ({ opacity: 0, filter: "blur(4px)", x: shift * d }), center: { opacity: 1, filter: "blur(0px)", x: 0 }, exit: (d: number) => ({ opacity: 0, filter: "blur(4px)", x: -shift * d }), }} initial="enter" animate="center" exit="exit" transition={{ duration: reduce ? 0 : 0.2 }} > {active ? ( <> <span className="flex items-center gap-2 text-[11px] font-medium text-muted-foreground"> <EventDot accent={accent} done={shownWeek < today} /> <span className="truncate"> {formatDate(weekDate(year, shownWeek))} · {status(shownWeek)} </span> {/* Two milestones in one week: step through them. */} {group && group.length > 1 && ( <button type="button" tabIndex={-1} aria-label="Next milestone this week" onClick={cycle} className="pointer-events-auto -mr-1.5 ml-auto shrink-0 cursor-pointer whitespace-nowrap rounded-full px-1.5 py-0.5 tabular-nums transition-colors hover:bg-foreground/6 hover:text-foreground" > {(sub % group.length) + 1} of {group.length} › </button> )} </span> <span className="mt-1 truncate text-sm font-semibold text-foreground">{active.title}</span> {active.detail && <span className="truncate text-xs text-muted-foreground">{active.detail}</span>} </> ) : ( <span className="whitespace-nowrap text-xs font-medium tabular-nums text-foreground"> {week === today ? "Today" : formatDate(weekDate(year, week))} </span> )} </motion.div> </AnimatePresence> </motion.div> </motion.div> {/* Notch tying the card to the playhead. */} <motion.svg aria-hidden width={NOTCH.w} height={NOTCH.h + 1} viewBox={`0 -1 ${NOTCH.w} ${NOTCH.h + 1}`} className="pointer-events-none absolute left-0 z-10 -translate-x-1/2 overflow-visible" style={{ top: CARD_AREA - CARD_GAP - 1 }} initial={false} animate={{ x: notchX, opacity: peeking ? 0.6 : 1 }} transition={{ ...move, opacity: { duration: 0.15 } }} > <path d={`M0 -1 L${NOTCH.w / 2} ${NOTCH.h} L${NOTCH.w} -1 Z`} fill="var(--muted)" /> <path d={`M0 0 L${NOTCH.w / 2} ${NOTCH.h} L${NOTCH.w} 0`} fill="none" stroke="color-mix(in srgb, var(--foreground) 8%, transparent)" strokeLinejoin="round" /> </motion.svg> {/* Ruler */} <div role="slider" tabIndex={0} aria-label={title} aria-valuemin={0} aria-valuemax={WEEKS - 1} aria-valuenow={week} aria-valuetext={valueText} className="absolute inset-x-0 cursor-ew-resize touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20 focus-visible:ring-offset-4 focus-visible:ring-offset-background" style={{ top: CARD_AREA, height: RULER_H }} onPointerEnter={() => animate(strength, 1, { duration: 0.2 })} onPointerLeave={() => !drag.current && fadeOut()} onPointerMove={(e) => { const x = pointerX(e); mx.set(x); const d = drag.current; if (!d) { setPeek(nearestEvent(toWeek(x), 1)); return; } if (!d.moved && Math.abs(x - d.startX) < DRAG_SLOP) return; d.moved = true; playX.stop(); const p = magnet(x); playX.set(p); setWeek(toWeek(p)); }} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); const x = pointerX(e); mx.set(x); animate(strength, 1, { duration: 0.2 }); drag.current = { startX: x, moved: false }; setDragging(true); setPeek(null); // A click glides the playhead over; clicking near a milestone lands on it. const near = nearestEvent(toWeek(x), 1); const target = near !== null ? near * spacing : magnet(x); playX.stop(); animate(playX, target, reduce ? INSTANT : GLIDE); setWeek(toWeek(target)); }} onPointerUp={release} onPointerCancel={release} onKeyDown={(e) => { const keys: Record<string, () => void> = { ArrowRight: () => step(1, e.shiftKey), ArrowLeft: () => step(-1, e.shiftKey), PageUp: () => step(1, true), PageDown: () => step(-1, true), Home: () => setWeek(0), End: () => setWeek(WEEKS - 1), Enter: () => cycle(), }; if (keys[e.key]) { e.preventDefault(); keys[e.key](); } }} > {Array.from({ length: WEEKS }, (_, i) => ( <Tick key={i} x={i * spacing} spacing={spacing} base={groups.has(i) ? TICK_EVENT : i === today ? TICK_MONTH + 4 : monthStarts.has(i) ? TICK_MONTH : TICK} color={groups.has(i) ? accent : undefined} count={groups.get(i)?.length ?? 0} strong={monthStarts.has(i) || i === today} future={i > today} isToday={i === today} current={i === week} mx={mx} strength={strength} magnify={reduce ? 0 : magnify} detent={!reduce} /> ))} {/* Playhead; its knob takes the accent while it rests on a milestone. */} <motion.div className="pointer-events-none absolute -top-2.5 bottom-0 left-0 z-1 w-0" style={{ x: playX }}> <div className="absolute inset-y-0 -left-px w-0.5 rounded-full bg-foreground" /> <motion.div className="absolute -left-1 top-0 size-2 rounded-full" initial={false} animate={{ backgroundColor: onEvent && !dragging ? accent : "var(--foreground)", scale: onEvent && !dragging ? 1.25 : 1 }} transition={{ duration: 0.2 }} /> </motion.div> </div> {/* Month labels */} {shownMonths.map((m) => ( <span key={m.label} className="absolute -translate-x-1/2 text-[11px] font-medium text-muted-foreground" style={{ left: Math.max(m.week * spacing, 8), top: CARD_AREA + RULER_H + 6 }} > {m.label} </span> ))} </> )} </div> </div> </div> ); } /** Filled once a milestone is done; a ring while it's still ahead. */ function EventDot({ accent, done, className }: { accent: string; done: boolean; className?: string }) { return ( <span className={cn("size-1.5 shrink-0 rounded-full", className)} style={done ? { background: accent } : { boxShadow: `inset 0 0 0 1.5px ${accent}`, background: "var(--background)" }} /> ); } function Tick({ x, spacing, base, color, count, strong, future, isToday, current, mx, strength, magnify, detent, }: { x: number; spacing: number; base: number; color?: string; /** Milestones on this week. */ count: number; strong: boolean; future: boolean; isToday: boolean; /** The playhead is on this tick. */ current: boolean; mx: MotionValue<number>; strength: MotionValue<number>; magnify: number; /** Flash as the playhead passes, like a detent on a dial. */ detent: boolean; }) { // Gaussian falloff around the pointer, like the macOS Dock. const sigma = spacing * 2.6; const lift = useTransform([mx, strength] as MotionValue<number>[], ([m, s]: number[]) => { const d = m - x; return s * Math.exp(-(d * d) / (2 * sigma * sigma)); }); const flash = useMotionValue(0); const mounted = useRef(false); useEffect(() => { if (!mounted.current) { mounted.current = true; return; } // Not stopped when the playhead moves on: a flash cut short would leave the tick lit. if (current && detent) animate(flash, [1, 0], { duration: 0.35, ease: "easeOut" }); }, [current, detent, flash]); const height = useTransform([lift, flash] as MotionValue<number>[], ([l, f]: number[]) => base * (1 + magnify * l) + f * 4); // Weeks still ahead are quieter than the ones behind. const rest = (isToday ? 0.85 : strong ? 0.4 : 0.22) * (future ? 0.55 : 1); const opacity = useTransform([lift, flash] as MotionValue<number>[], ([l, f]: number[]) => color ? (future ? 0.7 : 1) : Math.min(1, rest + l * 0.5 + f * 0.5), ); return ( <motion.div className="absolute bottom-0" style={{ left: x - 1, width: 2, height }}> <motion.div className="absolute inset-0 rounded-full" style={{ background: color ?? "var(--foreground)", opacity }} /> {color && ( <span className="absolute -top-2 left-1/2 -translate-x-1/2"> <EventDot accent={color} done={!future && !isToday} className="block" /> {count > 1 && ( <span className="absolute -top-1.5 left-2 text-[9px] font-semibold leading-none tabular-nums" style={{ color }}> {count} </span> )} </span> )} {/* Today: a small dot under the baseline. */} {isToday && <span className="absolute -bottom-2 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground" />} </motion.div> ); }
Adds the files to
components/uibrik/scrub-timeline and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/scrub-timeline.Props
ScrubTimeline- title
- Type string
- "2026 roadmap"
- Heading, also the slider's label.
- year
- Type number
- 2026
- Year the ruler spans, one tick per week.
- accent
- Type string
- "#f97316"
- Colour of milestones.
- magnify
- Type number
- 1.6
- How much ticks grow under the pointer; 0 turns it off.
- defaultWeek
- Type number
- 22
- Week the playhead starts on.
- events
- Type TimelineEvent[]
- –
- Milestones: { week (0–51), title, detail? }. Several in one week share a tick and the card steps through them.
- todayWeek
- Type number
- –
- Week to mark as today; earlier milestones show as done. Defaults to the real date.
- onChange
- Type (week: number) => void
- –
- Called as the playhead moves.
- className
- Type string
- –
- Classes for the outer shell.