# Dot Rail Changelog

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/dot-rail-changelog).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/dot-rail-changelog
- Screenshot: https://shadcn.exchange/shot/keyframeui/dot-rail-changelog/light.png
- Registry item: https://keyframeui.com/r/dot-rail-changelog.json
- Categories: blocks, changelog-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/dot-rail-changelog.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/dot-rail.json`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/changelog-sections/dot-rail-changelog

## Files

### `registry/blocks/dot-rail-changelog.tsx`

```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>
  );
}
```
