Timeline Map
TimelineMap — dated entities as linked dots on a shared time axis, one labeled strip per category lane.
Preview
Rendered from the story the storybook (a11y) CI gate runs axe against — the preview can't show something that hasn't been verified.
Install
Two equivalent paths — the URL works in any shadcn-CLI setup; the alias works once you've registered @interlace in your components.json.
npx shadcn@latest add https://ds.interlace.tools/r/timeline-map.jsonnpx shadcn@latest add @interlace/timeline-mapBehavior
What this component does — the keyboard path, the measured colour, the text equivalent and the states it can be in. Every figure below is read out of the test or the source that enforces it by scripts/build-behavior-map.mjs, so nothing here can outlive the gate that keeps it true.
Measured contrast
packages/ui/__tests__/theme-contract-lock.test.ts ↗Every semantic colour pair this component's own source uses, measured against the shipped hexes in all 4 theme × scheme combinations. The tightest is muted-foreground on muted at 7.34:1 (harbor · light), against a floor of 4.5:1.
| Pair | interlacelight | interlacedark | harborlight | harbordark | Floor |
|---|---|---|---|---|---|
| muted-foreground on mutedcopy on a muted surface | 8.81:1 | 10.48:1 | 7.34:1 | 8.64:1 | 4.5:1SC 1.4.3 |
| muted-foreground on backgroundsecondary copy | 9.41:1 | 11.32:1 | 7.82:1 | 9.52:1 | 4.5:1SC 1.4.3 |
| muted-foreground on cardsecondary copy in a card | 9.41:1 | 10.48:1 | 8.25:1 | 8.64:1 | 4.5:1SC 1.4.3 |
| foreground on backgroundbody copy | 19.65:1 | 16.97:1 | 17.74:1 | 16.19:1 | 4.5:1SC 1.4.3 |
Text pairs are held to 4.5:1 (WCAG 2.2 SC 1.4.3); borders, rings and chart axes to 3:1 (SC 1.4.11 non-text). Bold is the worst cell in the row — the one a brand fork has to keep an eye on.
Text equivalent
packages/ui/src/patterns/timeline-map.tsx ↗A chart's alt text is not a sentence about the chart — the equivalent of the data is the data. This is what a screen reader gets, read out of the source the install writes into your tree.
Ships sr-only copy for what the visual carries by position, colour or icon alone — a label, not a full data equivalent.
Runtime contract
packages/ui/vitest.config.ts ↗Where it renders, how narrow it survives, and whether its lines are inside the coverage gate — three answers a consumer needs before the paste, not after the bug.
- Render boundary
'use client'Hydrates. It brings JavaScript to any route that imports it — put the boundary here, not at the page.
- Minimum viewport
none declaredNo width floor: it takes the width it is given. Its container owns the layout question.
- Coverage gate
not in the gateThe 100% gate currently covers
src/charts/**/*.{ts,tsx}andsrc/lib/**/*.{ts,tsx}andsrc/primitives/{button-variants,data-state-model,meter-scale,skeleton-variants}.tsandsrc/primitives/{alert,aspect-ratio,avatar,badge,box,breadcrumb,button,callout,card,checkbox,code-editor,container,data-state,focus-ring,grade-badge,grid,input,label,meter,pagination,published-date,reading-time,section,section-boundary,separator,skip-link,stack,stat-strip,tag,textarea,typography,visually-hidden}.tsxandsrc/patterns/lint-playground.tsx. This file is outside it — the glob widens in phase 7.2.
History
This component is at v1.3.0. The version is stamped as a banner into the file the install writes, so the copy in your tree says which one you have — compare it with the number above before deciding whether to re-run the install.
Unreleased9 entries
- Addedminor
TimelineMap: number-axis mode — the lanes × axis landscape over any continuous measure New
axisprop ({ kind: "number", format }) positions dots byitem.valueinstead ofitem.date— reading minutes, bundle KB, any quantity a corpus is actually navigated by. Nice-step ticks (1/2/5×10ⁿ) render through the consumer'sformat; aria-labels and the Detail strip speak the formatted value through one shared voice, so the two can never disagree. The date axis, the weave, and every interaction are unchanged;dateis now optional at the type level (number-axis items don't need one). - Addedminor
TimelineMap: the reader's thread (
trace) A newtraceprop draws the reader's own path through the corpus — ordered visited ids threaded dot-to-dot in warm strand-a over the cool strand-b corpus web, with the draw verb (pathLength-normalized onto the sharedstrand-drawkeyframe; instantly complete under reduced motion). The overlay is decorative andtrace.labelspeaks the summary ("Your thread: 7 of 82 read"). Unknown and filtered-out ids skip, consecutive repeats collapse, and fewer than two visible points draws nothing — a single visited dot is a beginning, not yet a thread. SSR without the prop renders no trace: the honest crawler view, since reading history is client-only. - Addedminor
Toggle —
pillvariant +xssize; TimelineMap.Filter consumes it The filter pill (rounded-full chip, strand-a pressed tint, greyscale-safe border identity) is now a Toggle variant instead of classes open-coded inside TimelineMap.Filter — every "which categories/threads are active" surface styles from one home.xsis its native size:min-h-6sits exactly on the WCAG 2.2 SC 2.5.8 24×24 target floor (min-height, so a wrapped label grows the pill rather than clipping). TimelineMap.Filter now renders these Toggles — Base UI carriesaria-pressed— with no visual change.Components - Addedminor
The woven signature kit and the brand strand tokens. New:
TimelineMap(patterns) — dated entities as dots on category lanes with fit-all width, a roving-tabindex keyboard composite, and the link weave (item.linksdraws the corpus's internal reference graph as strand-b threads with select-to-illuminate interaction);InterlaceWeaveandDecodeText(effects/, new export subpath). New brand tokens--interlace-strand-a/--interlace-strand-bin both themes, with the WCAG 1.4.11 floor measured by the theme-contract lock. Fixed: the preflight appliedheight: autoto svg/canvas, defeating explicit sizing utilities on every viewBox'd svg — now photographic media only, matching Tailwind's own preflight. - Changedpatch
TimelineMap — dot hit radius 12 → 13 The nominal 24px hit union measured 23px in a real browser (sub-pixel rounding), one under the SC 2.5.8 floor. r=13 carries a 2px margin; still paints nothing.
- Changedpatch
The link weave budgets its rest ink by density: a sparse web keeps each thread readable (0.25), while a heavily cross-cited corpus (the blog's 735 threads) recedes into texture (>48 edges → 0.10, >160 → 0.04) so the dots stay primary. The illuminated selection keeps full strength at any density.
- Changedpatch
TimelineMap: filtering out the focused dot's lane no longer traps the keyboard — a focus id outside the visible order falls back to the recent end, so the composite always keeps exactly one tab stop.
- Changedpatch
TimelineMap — dot links get a 24px hit area The visible dots can be as small as 10px — under the 24px target floor of WCAG 2.2 SC 2.5.8, and genuinely hard to tap. Each dot link now carries a transparent r=12 hit circle behind the painted dot: pointer geometry grows to 24px, the visual stays the map's scale. Caught by the blog's layout audit at every viewport.
- Changedpatch
TimelineMap — filter pills clear the SC 2.5.8 and AA floors The category filter pills rendered 22px tall (under the 24px target floor of WCAG 2.2 SC 2.5.8) and hardcoded the count span to muted-foreground, which measured 4.37:1 on the active pill's strand-a/10 tint — under the 4.5 AA floor. Pills are now inline-flex min-h-6; the count inherits the pill's state colour. Caught by the blog's real-layout audit across every viewport.
Import
4 named exports — pull the parts you need.
import { TimelineMap, TimelineMapAxis, computeTimelineLayout, computeTracePath } from '@/components/ui/patterns/timeline-map';Anatomy
Extracted from the primitive's JSDoc header. The source is the only documentation that can't drift.
(R11 — compound, one memoized context R15)
<TimelineMap items={…} data-testid="…">
<TimelineMap.Filter /> — optional category chips (aria-pressed)
<TimelineMap.Chart /> — axis header + lanes + dots
<TimelineMap.Detail /> — fixed-height preview strip (CLS=0, R23)
</TimelineMap>
### Keyboard (R26)
| Key | Action |
| -------------- | --------------------------------------------------- |
| Tab | One stop: the current dot (roving tabindex) |
| → / ← | Next / previous dot in chronological order |
| Home / End | First / last visible dot |
| Enter | Activate the dot's link (native anchor) |
The app version made every dot a tab stop (89 stops on the blog corpus —
hostile). Roving tabindex per APG: one stop, arrows traverse.
### API parity (R17)
- `onItemClick(item)` mirrors MUI DataGrid's `onRowClick`: the anchor is
an internal part, so the DS surfaces its activation; navigation itself
stays native.
- `filter` + `defaultFilter` + `onFilterChange` follow the MUI/shadcn
controlled+uncontrolled convention (R14). URL-state wiring is the
consumer's job — the DS never touches routers.
- `linkComponent` (R10 `xxxComponent`) injects a framework Link; default
is a plain anchor.
### Encoding contract
x = the shared continuous axis — dates by default, or any numeric
measure via `axis={{ kind: "number", format }}` (reading minutes,
bundle KB): a landscape of category × whatever quantity the corpus
is actually navigated by. Row = category, dot diameter = optional
`weight` (0..1 → 10–16px; the Detail strip must spell the value out —
size is never the only carrier). Marks are single-hue `strand-a` (R19
token): identity is carried spatially by labeled lanes, so no
multi-hue palette is needed and none is offered.
Same-(lane, day) groups fan into a center-first 3-row beeswarm (a solo
dot sits centered; burst rows cap radius for lane-border clearance) —
without the fan, 43 of the blog's 89 dots were perfectly stacked.
Static markup is SSR-honest: every item renders as a real anchor with
its accessible name; no floating tooltip (the Detail strip reserves its
height).
### Fit-all width
The strip stretches to fill the container (ResizeObserver), so the
WHOLE territory — every item — is visible at once whenever space
allows. 560px is the floor: below it the strip keeps its size and
scrolls internally, resting at the recent end (state, not motion —
reduced-motion safe). SSR renders honestly at the floor.
### The link weave
`item.links` declares the corpus's internal reference graph, drawn as
strand-b threads between dots — the map shows not just WHEN and WHERE
things were published but how they weave into each other (the division
has an agenda; the threads make it legible). Interaction grammar from
the engage network graph: at rest the web is faint; touching a dot
(hover or keyboard focus — both set `previewed`) lights ITS threads
and recedes everything unrelated. State changes are instant — the
motion vocabulary stays draw/decode. The overlay is aria-hidden; the
Detail strip speaks the same links ("weaves into …") for screen
readers and crawlers.API reference
Parsed from the type declarations in the source — the same file the install writes into your tree, so this table can't drift from the component you get.
TimelineMapProps
Also accepts every <figure> attribute.
| Prop | Type | Description |
|---|---|---|
| items* | readonly TimelineMapItem[] | — |
| axis | TimelineMapAxis | Stable selector for E2E tests; consumer provides — no default (R5). */ 'data-testid': string; /** Axis semantics — see TimelineMapAxis. Pass a stable object (module constant or memo): the layout recomputes when its identity changes. @default { kind: "date" } |
| uncategorizedLabel | string | Lane label for items without a category. @default "Other" |
| filter | readonly string[] | Controlled set of visible categories (R14). Omit for uncontrolled. |
| defaultFilter | readonly string[] | Uncontrolled initial visible categories. @default all categories |
| onFilterChange | (categories: string[]) => void | Fires with the next visible-category set when a chip is toggled. |
| onItemPreview | (item: TimelineMapItem) => void | Fires when a dot gains hover/focus (drives the Detail strip too). |
| onItemClick | (item: TimelineMapItem) => void | Fires on dot activation, alongside native navigation. |
| linkComponent | React.ElementType | Framework link injected for dot anchors (R10). @default "a" |
| trace | TimelineMapTrace | The reader's own path through the corpus — see TimelineMapTrace. Client-only data (a reading history) belongs here AFTER hydration: SSR without it renders no trace, which is the honest crawler view. |
| children* | React.ReactNode | — |
TimelineMapFilterProps
Also accepts every <div> attribute.
TimelineMapChartProps
Also accepts every <div> attribute.
TimelineMapDetailProps
Also accepts every <div> attribute.
| Prop | Type | Description |
|---|---|---|
| idle | React.ReactNode | Shown before any dot has been previewed. |
| children | (item: TimelineMapItem) => React.ReactNode | — |
Accessibility
Every story for this component is rendered headlessly and checked with axe-core (wcag2aa, wcag22aa, best-practice, ACT) on every PR. That gate has no continue-on-error, so what ships has zero known violations.
What follows is what static analysis can see. Axe cannot press a key and never sees an overlay open, so the operable-without-a-mouse claim lives in Behavior instead, where the keyboard path is replayed step by step.
- Focus + keyboard behaviour
- Native element semantics — no interaction layer to get wrong.
- Focus ring (WCAG 2.2 SC 2.4.13)
- Inherited from the preflight focus contract shipped in @interlace/theme.
- Reduced motion
- No animation to gate.
- ARIA in the source
role="group"aria-labelaria-live
Examples
5 more states from the same story file.
Dependencies
- Base UI primitive
- Native / no Base UI dependency
- Lucide icons
- none
- NPM dependencies
- none
- Registry dependencies
Source
The full implementation — components/ui/patterns/timeline-map.tsx once installed.
'use client';
import * as React from 'react';
// @interlace/timeline-map v1.3.0 — Interlace design system.
// Docs, props and live preview: https://ds.interlace.tools/c/timeline-map
// What changed since: https://ds.interlace.tools/c/timeline-map#history
// Generated banner — keep it, the upgrade diff reads this version.
import { cn } from '@/lib/utils';
import { Toggle } from '@/components/ui/toggle';
/**
* TimelineMap — dated entities as linked dots on a shared time axis, one
* labeled strip per category lane.
*
* ## RFC (R3)
*
* Generalized from the blog's corpus map (100/100 Lighthouse accessibility
* + agentic on the live site), born complete with the two rungs the app
…1056 more lines…Registry JSON
The raw registry record — what the shadcn CLI fetches.