Changelog
✕July 27Change 1July 20Change 2July 13Change 3July 6Change 4June 29Change 5June 22Change 6June 15Change 7
Playground
Background
A scroll-driven changelog ruler. Dated pill markers glide past a fixed playhead as you scroll the panel, the marker under the head reading as active.
Code
"use client";
import { useEffect, useRef, useState } from "react";
import { CHANGES, type Change } from "./changelog/data";
import { ICONS } from "./changelog/icons";
export const meta = {
title: "Changelog",
date: "2026-07-17",
size: "lg" as const,
tags: ["UI", "Timeline"],
description:
"A scroll-driven changelog ruler. Dated pill markers glide past a fixed playhead as you scroll the panel, the marker under the head reading as active.",
prompt:
"Build a scroll-driven changelog ruler in React on a dark panel: a baseline with minor and major ticks (a major tick under each dated change) plus pill markers (icon + label). Scrolling the panel drifts the track past a fixed playhead near the left edge, and the marker nearest the head reads as active. Control for background colour.",
};
export const params = {
bg: "#0a0a0c",
};
export const controls = {
bg: { type: "color", label: "Background" },
} as const;
export const presets = {
Ink: { bg: "#0a0a0c" },
Navy: { bg: "#0a0f1c" },
Plum: { bg: "#120a16" },
};
type Params = typeof params;
const SANS = "ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
const SPACING = 168; // gap between changes, sets the ruler scale
const STEP = SPACING / 10; // one minor tick per tenth of that gap
const PLAYHEAD = 64; // x of the fixed marker
const BASELINE = 46; // y of the ruler line
const PILL_TOP = BASELINE + 16;
const FADE = "linear-gradient(180deg, transparent, #000 10%, #000 90%, transparent)";
function Marker({ change, x, row, active }: { change: Change; x: number; row: number; active: boolean }) {
const Icon = ICONS[change.icon];
return (
<>
<span
className="absolute -translate-x-1/2 whitespace-nowrap text-[10px] font-medium uppercase tracking-wide text-white/45"
style={{ left: x, top: 12 }}
>
{change.date.split(",")[0]}
</span>
<span
className={`absolute flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
active ? "bg-white text-black" : "border border-white/10 bg-white/5 text-white/70"
}`}
style={{ left: x, top: PILL_TOP + row * 30 }}
>
<Icon className="size-3.5 shrink-0" />
{change.title}
</span>
</>
);
}
function Track({ progress, playhead }: { progress: number; playhead: number }) {
const last = CHANGES.length - 1;
const width = CHANGES.length * SPACING;
const focus = SPACING / 2 + progress * last * SPACING;
const active = Math.round(progress * last);
return (
<div
className="absolute inset-y-0 left-0 will-change-transform"
style={{ width, transform: `translateX(${playhead - focus}px)` }}
>
<div className="absolute h-px bg-white/15" style={{ width, top: BASELINE }} />
{Array.from({ length: Math.floor(width / STEP) + 1 }, (_, k) => {
const kind = k % 10; // 5 = under a change, 0 = midway between
const h = kind === 5 ? 14 : kind === 0 ? 9 : 5;
return (
<span
key={k}
className="absolute w-px"
style={{
left: k * STEP,
top: BASELINE - h,
height: h,
background: kind === 5 ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.18)",
}}
/>
);
})}
{CHANGES.map((c, i) => (
<Marker key={c.title} change={c} x={i * SPACING + SPACING / 2} row={i % 2} active={i === active} />
))}
</div>
);
}
function Timeline({ progress, playhead = PLAYHEAD }: { progress: number; playhead?: number }) {
return (
<div
className="relative h-[136px] w-full overflow-hidden"
style={{ maskImage: FADE, WebkitMaskImage: FADE }}
>
<div
className="pointer-events-none absolute w-px bg-white/40"
style={{ left: playhead, top: 20, height: 34 }}
/>
<Track progress={progress} playhead={playhead} />
</div>
);
}
export default function Changelog({ bg }: Params) {
const scrollRef = useRef<HTMLDivElement>(null);
const [progress, setProgress] = useState(0);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
setProgress(max > 0 ? el.scrollTop / max : 0);
};
return (
<div
ref={scrollRef}
onScroll={onScroll}
className="h-full w-full overflow-y-auto text-white"
style={{ background: bg, fontFamily: SANS }}
>
<div className="sticky top-0 flex h-full items-center">
<Timeline progress={progress} />
</div>
<div className="h-[220%]" />
</div>
);
}
export function Card() {
const [progress, setProgress] = useState(0.5);
useEffect(() => {
const start = performance.now();
let raf = 0;
const loop = (now: number) => {
setProgress(Math.sin(((now - start) / 1000) * 0.16) * 0.5 + 0.5);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<div className="flex h-full w-full items-center bg-[#0a0a0c] text-white" style={{ fontFamily: SANS }}>
<Timeline progress={progress} playhead={150} />
</div>
);
}
dateJul 17, 2026
tagsUI, Timeline