Retro Timeline
A single-rail vertical timeline for changelogs, archives, CVs, and build logs. Renders a list of { id, date, title, description? } events along a 1px rail with a small accent dot pinned to each title. Pure layout — every chrome region is an optional ReactNode, the only data the rail consumes is an events[] array, and routing is delegated to an optional renderEventHref resolver. Generalised from the original retro shell where series / part grouping, month bucketing, and Next.js routing were baked in.
Milestones from the build log — newest first.
v3 shipped — retro shell becomes a primitive
Extracted the timeline rail into a slot-based layout. No more project-specific routing.
Design tokens unified across the docs site
Every retro component now reads --cb-* tokens. Themability landed without a fork.
First prototype of the archive timeline
Sketched a single-rail vertical layout with month buckets and inline badges.
Project genesis
Installation
npx shadcn@latest add https://craftbits.dev/r/retro-timeline.jsonUsage
Pass an array of event objects and let the rail render the timeline:
import { RetroTimeline } from "@craft-bits/core";
const events = [
{ id: "v3", date: "2026-05-22", title: "v3 shipped" },
{ id: "v2", date: "2026-05-08", title: "Design tokens unified" },
];
<RetroTimeline events={events} />;Wire it into your router by passing a renderEventHref resolver — each row becomes a focusable anchor:
<RetroTimeline
events={events}
renderEventHref={(event) => "/log/" + event.id}
/>;Drop content into the optional header, intro, footer, and eyebrow slots without forking the shell:
<RetroTimeline
events={events}
header={<MyMasthead />}
intro={<ArchiveBlurb />}
footer={<SiteFooter />}
eyebrow="/changelog"
/>;Understanding the component
- Pure layout, no controllers. The rail takes ReactNode slot props and an
events[]array and arranges them. No data fetching, no router coupling, no scroll listeners. The originalRetroTimelinebaked ingroupByMonth, series / part folding, and hardcoded Next.js routes; here all of that is the consumer's call. - Rail + dots via pseudo-element. The ordered list carries the rail as a
::beforepseudo-element so no extra wrapper div is needed. Each row pins a small accent dot to the title line via absolute positioning so the rail reads as continuous even at the compact density. - Plain rows or anchor rows. When
renderEventHrefis omitted, every row renders as a div — read-only timeline. When supplied, rows wrap inRetroTimelineEventLinkanchors with hover and:focus-visiblefeedback against--cb-bg-muted. - Tabular numerals on the date. Dates render as
<time>withfont-variant-numeric: tabular-numsso vertical columns line up when the rail is dense. - Three date formats.
dateFormatacceptslong(e.g. May 30, 2026),short(ISO date 2026-05-30, the default), oriso(the raw ISO string). All three are deterministic in UTC so SSR and the client never diverge. - Two density presets.
cozy(default) leaves vertical space for a one-line description and reads as an editorial archive;compacttightens row padding and rail gap to a dense changelog. - Eyebrow as an optional left-rail label. Defaults to
/archive — timeline(matching the original retro shell). Passnullto suppress, or any ReactNode to override. - Data-attributes for nested styling. The root carries
data-cb-retro-timeline,data-surface,data-density, anddata-align; every region tags itself withdata-cb-retro-timeline-regionso a single global rule can re-skin every instance.
Props
RetroTimeline
| Prop | Type | Default | Description |
|---|---|---|---|
events | ReadonlyArray<RetroTimelineEvent> | — | The event list rendered along the rail. Newest-first is conventional. |
header | ReactNode | — | Header pinned above the body. Wordmark + nav, banner, search bar. |
intro | ReactNode | — | Optional intro block rendered between the eyebrow and the rail. |
footer | ReactNode | — | Optional footer row pinned beneath the rail. |
eyebrow | ReactNode | null | '/archive — timeline' | Left-rail label. Pass null to suppress. |
surface | 'plain' | 'muted' | 'plain' | Page background — --cb-bg or --cb-bg-muted. |
density | 'cozy' | 'compact' | 'cozy' | Vertical rhythm between events. |
dateFormat | 'long' | 'short' | 'iso' | 'short' | Date label style. All three are UTC-deterministic for SSR safety. |
align | 'left' | 'split' | 'left' | Rail layout hint exposed as data-align for downstream theming. |
renderEventHref | (event: RetroTimelineEvent) => string | — | When supplied, rows render as anchors pointing at the returned href. |
listAriaLabel | string | 'Timeline' | aria-label for the rail region. |
className | string | — | Merged onto the rendered root via cn(). |
...rest | Omit<HTMLAttributes<HTMLDivElement>, 'title'> | — | Any other div attribute. |
RetroTimelineEvent
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable unique id used for React keys and the default href fallback. |
date | string | number | yes | Event date — parseable string or epoch milliseconds. |
title | string | yes | Display title shown as the row's primary label. |
description | string | no | Optional one-line summary rendered under the title. |
Accessibility
- The rail is rendered as an ordered list with
aria-label="Timeline"(override vialistAriaLabel). Each event is a list item so screen readers announce position-in-set. - The accent dot on each row is
aria-hidden="true"so it stays visual-only and never gets read out. - Every anchored row has visible
:focus-visiblestyling — a ring in--cb-accentplus a background lift to--cb-bg-muted. - The date is wrapped in
<time>with adateTimeattribute set to the ISO representation so AT and search engines read the structured value. - No motion — the rail never animates layout. Hover and focus transitions stay under 200ms and are colour-only, so reduced-motion preferences are respected implicitly.
- Color contrast: every default surface paints
--cb-bg/--cb-bg-mutedwith--cb-fg,--cb-fg-muted, and--cb-fg-subtletext. All three pass WCAG AA against both surfaces.
Credits
- Extracted from:
terminal-dreams(src/components/retro/RetroTimeline.tsx). The original was bound to aTimelinePostshape with project-specific concerns baked in:kind: "post" | "recipe", series / part grouping that re-folded items into nested cards,groupByMonthbucketing, hardcoded Next.js routes, a CSS module of[var(--color-*)]tokens, and a static/archive — timelineleft rail. craft-bits widens the API to a pure vertical-rail layout:events[]={ id, date, title, description? }, CVA variants forsurface/density/dateFormat/align,header/intro/footer/eyebrowslots, and arenderEventHrefescape hatch for routing.