A changelog laid over a year. The rail is the year, with a tick at each release and the months under it; its lit dots sit on the newest release. Point at a release or tab to it and they run across the year to its date with a short comet tail, then back to the newest when you leave.
registry/blocks/dot-rail-changelog.tsx
"use client";
import * as React from "react";
import { DotRail } from "@/components/ui/dot-rail";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the section yours: the
* palette, the copy, the year and the releases. The rail is the
* year, with a tick at each release. Its lit dots sit on the
* newest one; point at a release or tab to it and they run
* across the year to its date, then back to the newest when you
* leave.
* ─────────────────────────────────────────────────────────── */
const PALETTE = {
light: { paper: "#fafaf9", ink: "#0b0b0d", accent: "#2747ff", signal: "#ff5a1f" },
dark: { paper: "#07080c", ink: "#f4f4f6", accent: "#8292ff", signal: "#ff6a2b" },
};
const HEADLINE = { before: "A year of", accent: "departures" };
const BODY = "A train leaves every Tuesday at ten. These are the ones worth knowing about.";
const ALL_NOTES = { label: "Every release", href: "#changelog" };
const YEAR = 2026;
const MONTHS = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
/** Half the lit run's width, as a share of the year. */
const SPREAD = 0.011;
type Kind = "New" | "Improved" | "Fixed";
type Release = { train: string; date: string; kind: Kind; title: string; body: string; href: string };
/** Oldest first; `date` is YYYY-MM-DD in `YEAR`. */
const RELEASES: Release[] = [
{
train: "Train 171",
date: "2026-01-13",
kind: "New",
title: "Express trains",
body: "A hotfix can leave mid-week on its own two-stop route.",
href: "#train-171",
},
{
train: "Train 178",
date: "2026-03-03",
kind: "New",
title: "Canary limits per route",
body: "A slow export page can't stop the whole train any more.",
href: "#train-178",
},
{
train: "Train 189",
date: "2026-05-19",
kind: "Improved",
title: "Changelogs that write themselves",
body: "Every train's notes are drafted from the tickets that rode it.",
href: "#train-189",
},
{
train: "Train 199",
date: "2026-07-28",
kind: "New",
title: "Hold a change at the platform",
body: "It skips this train and boards the next one, checks intact.",
href: "#train-199",
},
{
train: "Train 207",
date: "2026-09-22",
kind: "Improved",
title: "Rollbacks in 11 minutes",
body: "Down from 26 at the median, without anyone paging the on-call.",
href: "#train-207",
},
{
train: "Train 213",
date: "2026-11-03",
kind: "Fixed",
title: "Checks run as changes board",
body: "Not all at once at the cut, so Tuesday mornings stay quiet.",
href: "#train-213",
},
];
/* ── Section ─────────────────────────────────────────────── */
/** Where a date falls in the year, from 0 to 1. */
function along(date: string) {
const start = Date.UTC(YEAR, 0, 1);
const end = Date.UTC(YEAR + 1, 0, 1);
const [y, m, d] = date.split("-").map(Number);
return (Date.UTC(y, m - 1, d) - start) / (end - start);
}
function shortDate(date: string) {
const [y, m, d] = date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
}
export function DotRailChangelog() {
const latest = RELEASES.length - 1;
const [pointed, setPointed] = React.useState<number | null>(null);
const [focused, setFocused] = React.useState<number | null>(null);
const shown = pointed ?? focused ?? latest;
const at = along(RELEASES[shown].date);
return (
<section
style={
{
"--paper-light": PALETTE.light.paper,
"--ink-light": PALETTE.light.ink,
"--accent-light": PALETTE.light.accent,
"--signal-light": PALETTE.light.signal,
"--paper-dark": PALETTE.dark.paper,
"--ink-dark": PALETTE.dark.ink,
"--accent-dark": PALETTE.dark.accent,
"--signal-dark": PALETTE.dark.signal,
} as React.CSSProperties
}
className="bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--dot-rail-accent:var(--signal)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] [--signal:var(--signal-light)] dark:[--accent:var(--accent-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)] dark:[--signal:var(--signal-dark)]"
>
<div className="mx-auto max-w-4xl px-6 py-24 sm:py-28">
<div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-4">
<div>
<h2 className="font-serif text-4xl leading-[1.05] font-normal tracking-[-0.02em] sm:text-5xl">
{HEADLINE.before} <em className="text-(--accent)">{HEADLINE.accent}</em>
</h2>
<p className="mt-3 text-base leading-relaxed opacity-70">{BODY}</p>
</div>
<a
href={ALL_NOTES.href}
className="rounded-sm text-sm font-medium underline decoration-current/30 underline-offset-4 transition-[text-decoration-color] duration-150 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)"
>
{ALL_NOTES.label}
</a>
</div>
{/* The year: the rail, a tick at each release and the months under it. */}
<div aria-hidden className="mt-12">
<div className="relative h-2">
{RELEASES.map((release, index) => (
<span
key={release.train}
className="absolute bottom-0 h-1.5 w-px -translate-x-1/2 bg-current transition-opacity duration-150"
style={{ left: `${along(release.date) * 100}%`, opacity: index === shown ? 1 : 0.3 }}
/>
))}
</div>
<DotRail value={[at - SPREAD, at + SPREAD]} className="mt-1.5" />
<div className="relative mt-2 h-4 font-mono text-[10px] tracking-[0.1em] opacity-45">
{MONTHS.map((month, i) => (
<span key={i} className="absolute -translate-x-1/2" style={{ left: `${((i + 0.5) / 12) * 100}%` }}>
{month}
</span>
))}
</div>
</div>
<ol className="mt-10 divide-y divide-current/10 border-y border-current/10" onPointerLeave={() => setPointed(null)}>
{[...RELEASES].reverse().map((release) => {
const index = RELEASES.indexOf(release);
return (
<li key={release.train}>
<a
href={release.href}
onPointerEnter={(e) => e.pointerType === "mouse" && setPointed(index)}
onFocus={() => setFocused(index)}
onBlur={() => setFocused((f) => (f === index ? null : f))}
className="group grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-4 gap-y-1 py-4 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) sm:grid-cols-[5.5rem_minmax(0,1fr)_auto]"
>
<time
dateTime={release.date}
className="font-mono text-[11px] tracking-[0.08em] uppercase tabular-nums opacity-55 transition-opacity duration-150 group-hover:opacity-100"
>
{shortDate(release.date)}
</time>
<span className="min-w-0">
<span className="font-medium">{release.title}</span>
<span className="mt-0.5 block text-sm leading-relaxed text-pretty opacity-65">{release.body}</span>
</span>
<span
className={
release.kind === "New"
? "col-start-2 font-mono text-[10px] tracking-[0.1em] whitespace-nowrap text-(--accent) uppercase sm:col-start-3 sm:text-right"
: "col-start-2 font-mono text-[10px] tracking-[0.1em] whitespace-nowrap uppercase opacity-50 sm:col-start-3 sm:text-right"
}
>
{release.kind} · {release.train.replace("Train ", "#")}
</span>
</a>
</li>
);
})}
</ol>
</div>
</section>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/changelog-sections/dot-rail-changelog