Scrubber
- Launch
- Press call
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes card-scrubber-in { from { opacity: 0; transform: translateY(var(--from)) rotateX(var(--tilt)); } }
.card-scrubber {
width: 252px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.card-scrubber__panel { height: 112px; padding: 12px 14px 0; perspective: 500px; overflow: hidden; }
.card-scrubber__month { color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
.card-scrubber__day { animation: card-scrubber-in 0.32s cubic-bezier(0.2, 0.8, 0.3, 1); transform-origin: 50% 0; }
.card-scrubber__date { margin-top: 2px; color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.card-scrubber__events { display: grid; gap: 5px; margin: 8px 0 0; padding: 0; list-style: none; }
.card-scrubber__events li { display: flex; gap: 8px; align-items: baseline; color: light-dark(#42454c, #e5e6e9); font-size: 11px; }
.card-scrubber__events time { width: 34px; color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
.card-scrubber__none { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.card-scrubber__ruler {
position: relative;
height: 58px;
border-top: 1px solid light-dark(#ebedf0, #232529);
cursor: grab;
touch-action: none;
}
.card-scrubber__ruler:active { cursor: grabbing; }
.card-scrubber__ruler:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: -2px; border-radius: 0 0 18px 18px; }
.card-scrubber__ruler canvas { display: block; width: 252px; height: 58px; mask-image: linear-gradient(90deg, transparent, #000 24%, #000 76%, transparent); }
.card-scrubber__needle { position: absolute; left: 50%; top: 30px; bottom: 0; width: 2px; margin-left: -1px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); pointer-events: none; }
.card-scrubber__needle::before {
content: '';
position: absolute;
left: -4px;
top: -4px;
border: 5px solid transparent;
border-top-color: light-dark(#373a40, #f1f2f3);
}
.card-scrubber__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-scrubber__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.card-scrubber__probe i:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.card-scrubber__day { animation: none; }
}
`;
const W = 252;
const H = 58;
const GAP = 26; // px per day
const DAYS = 30;
const MAX = (DAYS - 1) * GAP;
const EVENTS: Record<number, [string, string][]> = {
2: [['09:30', 'Sprint planning']],
4: [['14:00', 'Design review']],
6: [['15:30', 'Demo day'], ['19:00', 'Team dinner']],
9: [['11:00', 'User interviews']],
12: [['10:00', 'Launch'], ['11:30', 'Press call']],
13: [['16:00', 'Retro']],
17: [['08:45', 'Flight to Lisbon']],
18: [['All day', 'Motion meetup']],
20: [['13:00', 'Portfolio review']],
24: [['10:00', 'Quarterly planning'], ['15:00', '1:1 with Maya']],
26: [['18:30', 'Climbing']],
30: [['17:00', 'Month wrap-up']],
};
const label = (d: number) => new Date(2026, 10, d).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' });
export function ScrubberCard() {
const canvas = useRef<HTMLCanvasElement>(null);
const ruler = useRef<HTMLDivElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const [day, setDay] = useState(12);
const [dir, setDir] = useState(1);
const m = useRef({ pos: 11 * GAP, vel: 0, mode: 'snap' as 'drag' | 'free' | 'snap', target: 11 * GAP, day: 12, hold: 0, drag: { x: 0, pos: 0, hist: [] as [number, number][] } });
useEffect(() => {
const el = canvas.current!;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = W * dpr;
el.height = H * dpr;
const ctx = el.getContext('2d')!;
ctx.scale(dpr, dpr);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const s = m.current;
let last = performance.now();
let raf = 0;
const tick = (now: number) => {
const dt = Math.min(0.04, (now - last) / 1000);
last = now;
if (s.mode === 'free') {
// A flick glides on, slowing like a wheel on a bearing; past either end it is pulled back.
s.vel *= Math.exp(-dt * 2.6);
if (s.pos < 0 || s.pos > MAX) s.vel += ((s.pos < 0 ? 0 : MAX) - s.pos) * 60 * dt - s.vel * 8 * dt;
s.pos += s.vel * dt;
// Wheel input holds the ruler where it is until the scrolling stops, then it snaps.
if (Math.abs(s.vel) < 90 && now > s.hold) {
s.mode = 'snap';
s.target = Math.max(0, Math.min(MAX, Math.round(s.pos / GAP) * GAP));
}
} else if (s.mode === 'snap') {
if (still) {
s.pos = s.target;
s.vel = 0;
} else {
s.vel += ((s.target - s.pos) * 170 - s.vel * 20) * dt;
s.pos += s.vel * dt;
}
}
const d = Math.max(1, Math.min(DAYS, Math.round(s.pos / GAP) + 1));
if (d !== s.day) {
setDir(d > s.day ? 1 : -1);
s.day = d;
setDay(d);
}
const [dim, ink] = [...probe.current!.children].map((c) => getComputedStyle(c).color);
ctx.clearRect(0, 0, W, H);
ctx.textAlign = 'center';
for (let i = 0; i < DAYS; i++) {
for (let sub = 0; sub < 4; sub++) {
const x = W / 2 + i * GAP + (sub * GAP) / 4 - s.pos;
if (x < -10 || x > W + 10 || (i === DAYS - 1 && sub)) continue;
// Fisheye: everything near the needle swells.
const mag = Math.exp(-(((x - W / 2) / 42) ** 2));
const major = sub === 0;
const len = major ? 9 + 15 * mag : 4 + 5 * mag;
ctx.globalAlpha = major ? 0.45 + 0.55 * mag : 0.35;
ctx.fillStyle = major && mag > 0.85 ? ink : dim;
ctx.fillRect(x - (major ? 0.75 : 0.5), H - len, major ? 1.5 : 1, len);
if (!major) continue;
ctx.font = `${mag > 0.85 ? 700 : 500} ${(9 + 5 * mag).toFixed(1)}px system-ui, sans-serif`;
ctx.fillText(String(i + 1), x, 14 + (1 - mag) * 2);
if (EVENTS[i + 1]) {
ctx.beginPath();
ctx.arc(x, 21 + mag * 2, 1.6 + mag, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
// Wheel and trackpad scroll the ruler; a native listener so the page doesn't scroll with it.
const r = ruler.current!;
const wheel = (e: WheelEvent) => {
e.preventDefault();
s.mode = 'free';
s.pos = Math.max(-GAP, Math.min(MAX + GAP, s.pos + (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY)));
s.vel = 0;
s.hold = performance.now() + 140;
};
r.addEventListener('wheel', wheel, { passive: false });
return () => {
cancelAnimationFrame(raf);
r.removeEventListener('wheel', wheel);
};
}, []);
const go = (d: number) => {
const s = m.current;
s.mode = 'snap';
s.target = (Math.max(1, Math.min(DAYS, d)) - 1) * GAP;
};
const events = EVENTS[day];
return (
<>
<style>
{css}
</style>
<div className="card-scrubber" role="group" aria-label="November schedule">
<span ref={probe} className="card-scrubber__probe" aria-hidden="true">
<i />
<i />
</span>
<div className="card-scrubber__panel" aria-live="polite">
<div className="card-scrubber__month">November 2026</div>
<div
key={day}
className="card-scrubber__day"
style={{ '--from': `${dir * 10}px`, '--tilt': `${dir * -50}deg` } as React.CSSProperties}
>
<div className="card-scrubber__date">{label(day)}</div>
{events ? (
<ul className="card-scrubber__events">
{events.map(([t, what]) => (
<li key={what}>
<time>{t}</time>
{what}
</li>
))}
</ul>
) : (
<div className="card-scrubber__events card-scrubber__none">Nothing planned</div>
)}
</div>
</div>
<div
ref={ruler}
className="card-scrubber__ruler"
role="slider"
tabIndex={0}
aria-label="Day"
aria-valuemin={1}
aria-valuemax={DAYS}
aria-valuenow={day}
aria-valuetext={new Date(2026, 10, day).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
const s = m.current;
s.mode = 'drag';
s.vel = 0;
s.drag = { x: e.clientX, pos: s.pos, hist: [[performance.now(), e.clientX]] };
}}
onPointerMove={(e) => {
const s = m.current;
if (s.mode !== 'drag') return;
const over = (p: number) => (p < 0 ? p * 0.35 : p > MAX ? MAX + (p - MAX) * 0.35 : p);
s.pos = over(s.drag.pos - (e.clientX - s.drag.x));
s.drag.hist = [...s.drag.hist, [performance.now(), e.clientX] as [number, number]].slice(-5);
}}
onPointerUp={() => {
const s = m.current;
if (s.mode !== 'drag') return;
// Release velocity from the last few moves, so a flick carries on.
const h = s.drag.hist;
const [t0, x0] = h[0];
const [t1, x1] = h[h.length - 1];
const v = t1 > t0 ? -((x1 - x0) / (t1 - t0)) * 1000 : 0;
s.vel = performance.now() - t1 > 80 ? 0 : v;
s.mode = 'free';
}}
onKeyDown={(e) => {
const step = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
if (step) {
e.preventDefault();
go(m.current.day + step);
}
if (e.key === 'Home') go(1);
if (e.key === 'End') go(DAYS);
}}
>
<canvas ref={canvas} aria-hidden="true" />
<span className="card-scrubber__needle" aria-hidden="true" />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardScrubber(el) on the mounted element instead.
<div class="card-scrubber" role="group" aria-label="November schedule">
<span class="card-scrubber__probe" aria-hidden="true">
<i></i>
<i></i>
</span>
<div class="card-scrubber__panel" aria-live="polite">
<div class="card-scrubber__month">November 2026</div>
<div class="card-scrubber__day" style="--from:10px;--tilt:-50deg">
<div class="card-scrubber__date">Thu 12</div>
<ul class="card-scrubber__events">
<li><time>10:00</time>Launch</li>
<li><time>11:30</time>Press call</li>
</ul>
</div>
</div>
<div class="card-scrubber__ruler" role="slider" tabindex="0" aria-label="Day" aria-valuemin="1" aria-valuemax="30" aria-valuenow="12" aria-valuetext="Thursday 12 November">
<canvas aria-hidden="true"></canvas>
<span class="card-scrubber__needle" aria-hidden="true"></span>
</div>
</div>@keyframes card-scrubber-in { from { opacity: 0; transform: translateY(var(--from)) rotateX(var(--tilt)); } }
.card-scrubber {
width: 252px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
.card-scrubber__panel { height: 112px; padding: 12px 14px 0; perspective: 500px; overflow: hidden; }
.card-scrubber__month { color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; }
.card-scrubber__day { animation: card-scrubber-in 0.32s cubic-bezier(0.2, 0.8, 0.3, 1); transform-origin: 50% 0; }
.card-scrubber__date { margin-top: 2px; color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.card-scrubber__events { display: grid; gap: 5px; margin: 8px 0 0; padding: 0; list-style: none; }
.card-scrubber__events li { display: flex; gap: 8px; align-items: baseline; color: light-dark(#42454c, #e5e6e9); font-size: 11px; }
.card-scrubber__events time { width: 34px; color: light-dark(#5f626a, #a7abb2); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
.card-scrubber__none { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.card-scrubber__ruler {
position: relative;
height: 58px;
border-top: 1px solid light-dark(#ebedf0, #232529);
cursor: grab;
touch-action: none;
}
.card-scrubber__ruler:active { cursor: grabbing; }
.card-scrubber__ruler:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: -2px; border-radius: 0 0 18px 18px; }
.card-scrubber__ruler canvas { display: block; width: 252px; height: 58px; mask-image: linear-gradient(90deg, transparent, #000 24%, #000 76%, transparent); }
.card-scrubber__needle { position: absolute; left: 50%; top: 30px; bottom: 0; width: 2px; margin-left: -1px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); pointer-events: none; }
.card-scrubber__needle::before {
content: '';
position: absolute;
left: -4px;
top: -4px;
border: 5px solid transparent;
border-top-color: light-dark(#373a40, #f1f2f3);
}
.card-scrubber__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.card-scrubber__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.card-scrubber__probe i:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.card-scrubber__day { animation: none; }
}function initCardScrubber(root) {
var canvas = root.querySelector('canvas');
var ruler = root.querySelector('.card-scrubber__ruler');
var probe = root.querySelector('.card-scrubber__probe');
var panel = root.querySelector('.card-scrubber__panel');
if (!canvas || !ruler || !probe || !panel) return;
var W = 252;
var H = 58;
var GAP = 26; // px per day
var DAYS = 30;
var MAX = (DAYS - 1) * GAP;
var EVENTS = {
2: [['09:30', 'Sprint planning']],
4: [['14:00', 'Design review']],
6: [['15:30', 'Demo day'], ['19:00', 'Team dinner']],
9: [['11:00', 'User interviews']],
12: [['10:00', 'Launch'], ['11:30', 'Press call']],
13: [['16:00', 'Retro']],
17: [['08:45', 'Flight to Lisbon']],
18: [['All day', 'Motion meetup']],
20: [['13:00', 'Portfolio review']],
24: [['10:00', 'Quarterly planning'], ['15:00', '1:1 with Maya']],
26: [['18:30', 'Climbing']],
30: [['17:00', 'Month wrap-up']],
};
function label(d) { return new Date(2026, 10, d).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' }); }
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = W * dpr;
canvas.height = H * dpr;
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var s = { pos: 11 * GAP, vel: 0, mode: 'snap', target: 11 * GAP, day: 12, hold: 0, drag: null };
var last = performance.now();
var raf = 0;
// The day panel, rebuilt with a flip-in from the direction of travel.
function showDay(d, dir) {
var old = panel.querySelector('.card-scrubber__day');
var el = document.createElement('div');
el.className = 'card-scrubber__day';
el.style.setProperty('--from', dir * 10 + 'px');
el.style.setProperty('--tilt', dir * -50 + 'deg');
var date = document.createElement('div');
date.className = 'card-scrubber__date';
date.textContent = label(d);
el.appendChild(date);
var events = EVENTS[d];
if (events) {
var ul = document.createElement('ul');
ul.className = 'card-scrubber__events';
events.forEach(function (ev) {
var li = document.createElement('li');
var t = document.createElement('time');
t.textContent = ev[0];
li.appendChild(t);
li.appendChild(document.createTextNode(ev[1]));
ul.appendChild(li);
});
el.appendChild(ul);
} else {
var none = document.createElement('div');
none.className = 'card-scrubber__events card-scrubber__none';
none.textContent = 'Nothing planned';
el.appendChild(none);
}
if (old) old.replaceWith(el);
else panel.appendChild(el);
ruler.setAttribute('aria-valuenow', String(d));
ruler.setAttribute('aria-valuetext', new Date(2026, 10, d).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' }));
}
function tick(now) {
var dt = Math.min(0.04, (now - last) / 1000);
last = now;
if (s.mode === 'free') {
// A flick glides on, slowing like a wheel on a bearing; past either end it is pulled back.
s.vel *= Math.exp(-dt * 2.6);
if (s.pos < 0 || s.pos > MAX) s.vel += ((s.pos < 0 ? 0 : MAX) - s.pos) * 60 * dt - s.vel * 8 * dt;
s.pos += s.vel * dt;
// Wheel input holds the ruler where it is until the scrolling stops, then it snaps.
if (Math.abs(s.vel) < 90 && now > s.hold) {
s.mode = 'snap';
s.target = Math.max(0, Math.min(MAX, Math.round(s.pos / GAP) * GAP));
}
} else if (s.mode === 'snap') {
if (still) {
s.pos = s.target;
s.vel = 0;
} else {
s.vel += ((s.target - s.pos) * 170 - s.vel * 20) * dt;
s.pos += s.vel * dt;
}
}
var d = Math.max(1, Math.min(DAYS, Math.round(s.pos / GAP) + 1));
if (d !== s.day) {
showDay(d, d > s.day ? 1 : -1);
s.day = d;
}
var dim = getComputedStyle(probe.children[0]).color;
var ink = getComputedStyle(probe.children[1]).color;
ctx.clearRect(0, 0, W, H);
ctx.textAlign = 'center';
for (var i = 0; i < DAYS; i++) {
for (var sub = 0; sub < 4; sub++) {
var x = W / 2 + i * GAP + (sub * GAP) / 4 - s.pos;
if (x < -10 || x > W + 10 || (i === DAYS - 1 && sub)) continue;
// Fisheye: everything near the needle swells.
var mag = Math.exp(-Math.pow((x - W / 2) / 42, 2));
var major = sub === 0;
var len = major ? 9 + 15 * mag : 4 + 5 * mag;
ctx.globalAlpha = major ? 0.45 + 0.55 * mag : 0.35;
ctx.fillStyle = major && mag > 0.85 ? ink : dim;
ctx.fillRect(x - (major ? 0.75 : 0.5), H - len, major ? 1.5 : 1, len);
if (!major) continue;
ctx.font = (mag > 0.85 ? 600 : 500) + ' ' + (9 + 5 * mag).toFixed(1) + 'px system-ui, sans-serif';
ctx.fillText(String(i + 1), x, 14 + (1 - mag) * 2);
if (EVENTS[i + 1]) {
ctx.beginPath();
ctx.arc(x, 21 + mag * 2, 1.6 + mag, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
raf = requestAnimationFrame(tick);
}
function go(d) {
s.mode = 'snap';
s.target = (Math.max(1, Math.min(DAYS, d)) - 1) * GAP;
}
function down(e) {
ruler.setPointerCapture(e.pointerId);
s.mode = 'drag';
s.vel = 0;
s.drag = { x: e.clientX, pos: s.pos, hist: [[performance.now(), e.clientX]] };
}
function move(e) {
if (s.mode !== 'drag') return;
var p = s.drag.pos - (e.clientX - s.drag.x);
s.pos = p < 0 ? p * 0.35 : p > MAX ? MAX + (p - MAX) * 0.35 : p;
s.drag.hist = s.drag.hist.concat([[performance.now(), e.clientX]]).slice(-5);
}
function up() {
if (s.mode !== 'drag') return;
// Release velocity from the last few moves, so a flick carries on.
var h = s.drag.hist;
var a = h[0];
var b = h[h.length - 1];
var v = b[0] > a[0] ? -((b[1] - a[1]) / (b[0] - a[0])) * 1000 : 0;
s.vel = performance.now() - b[0] > 80 ? 0 : v;
s.mode = 'free';
}
function key(e) {
var step = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
if (step) {
e.preventDefault();
go(s.day + step);
}
if (e.key === 'Home') go(1);
if (e.key === 'End') go(DAYS);
}
// Wheel and trackpad scroll the ruler; not passive, so the page doesn't scroll with it.
function wheel(e) {
e.preventDefault();
s.mode = 'free';
s.pos = Math.max(-GAP, Math.min(MAX + GAP, s.pos + (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY)));
s.vel = 0;
s.hold = performance.now() + 140;
}
ruler.addEventListener('pointerdown', down);
ruler.addEventListener('pointermove', move);
ruler.addEventListener('pointerup', up);
ruler.addEventListener('keydown', key);
ruler.addEventListener('wheel', wheel, { passive: false });
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
ruler.removeEventListener('pointerdown', down);
ruler.removeEventListener('pointermove', move);
ruler.removeEventListener('pointerup', up);
ruler.removeEventListener('keydown', key);
ruler.removeEventListener('wheel', wheel);
};
}
document.querySelectorAll('.card-scrubber').forEach(initCardScrubber);Goal: Create a "Scrubber" card.
Visual behavior: A month schedule you flick through: drag or flick the ruler and it glides on with momentum before snapping to a day, the ticks swell under the needle like a dock, and each day's events flip in from the direction you moved.
Technique:
- The ruler is a canvas: day ticks every 26px with four sub-ticks, numbers above, and a dot on days with events. A fisheye gain exp(-(d / 42)^2) on distance from the needle scales tick height, label size and opacity.
- Drag moves the position directly (with rubber-band resistance past either end); release takes the speed of the last few pointer samples and glides with exponential friction.
- Below 90px/s it switches to a spring that settles on the nearest day. Wheel input moves it and holds 140ms before snapping; arrow keys, Home and End jump days.
- When the centred day changes, the panel re-renders with a flip-in keyframe whose direction and tilt come from the way you moved.
Accessibility: The ruler is a slider (role="slider") with aria-valuetext naming the full date; arrows, Home and End move it. The day panel is an aria-live region. Under prefers-reduced-motion it snaps without gliding or flipping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Scrubber" card.
Visual behavior: A month schedule you flick through: drag or flick the ruler and it glides on with momentum before snapping to a day, the ticks swell under the needle like a dock, and each day's events flip in from the direction you moved.
Technique:
- The ruler is a canvas: day ticks every 26px with four sub-ticks, numbers above, and a dot on days with events. A fisheye gain exp(-(d / 42)^2) on distance from the needle scales tick height, label size and opacity.
- Drag moves the position directly (with rubber-band resistance past either end); release takes the speed of the last few pointer samples and glides with exponential friction.
- Below 90px/s it switches to a spring that settles on the nearest day. Wheel input moves it and holds 140ms before snapping; arrow keys, Home and End jump days.
- When the centred day changes, the panel re-renders with a flip-in keyframe whose direction and tilt come from the way you moved.
Accessibility: The ruler is a slider (role="slider") with aria-valuetext naming the full date; arrows, Home and End move it. The day panel is an aria-live region. Under prefers-reduced-motion it snaps without gliding or flipping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A month schedule you flick through: drag or flick the ruler and it glides on with momentum before snapping to a day, the ticks swell under the needle like a dock, and each day's events flip in from the direction you moved.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, spring, state change, card
- Techniques
- raf, spring, event position, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The ruler is a slider (role="slider") with aria-valuetext naming the full date; arrows, Home and End move it. The day panel is an aria-live region. Under prefers-reduced-motion it snaps without gliding or flipping.