Version 53
Oct 10, 10:35 AMRefined Quillo node parameters and updated message formatting.
Choose your reading theme.
A workflow’s saved versions as dots on a date-by-time timeline, with a tray of version cards to page through and a 30-day range to slide.
version-history/version-history.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
When not to use
You can add this component with the shadcn CLI or by hand:
Into components/version-history of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/version-history.jsonCopy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
import { VersionHistory } from './version-history';import { SPOTS } from './versions';export function WorkflowHistory({ open }: { open: (version: number) => void }) { return ( <VersionHistory autoplay={false} spots={SPOTS} start="2025-09-01" days={93} onCompare={(version) => open(version.number)} onDetails={(version) => open(version.number)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A version history panel docked under a workflow canvas. Each dot holds the versions saved on one day at about the same hour: across is the date and down is the time of day. A dot grows with how much changed and turns green when a version went live. Hover or focus a dot and a tray of cards opens above it, one per version, to page through with Back and Next. Under the dots, a 30-day range slides along the months.
| Prop | Type | Default | Description |
|---|---|---|---|
spots | Spot[] | SPOTS | The dots: each is a day and the versions saved then, oldest first. |
start | string | '2025-09-01' | The timeline’s first day, as “YYYY-MM-DD”. |
days | number | 93 | How many days the timeline shows. |
span | number | 30 | How many days the range covers. |
defaultSpot | number | 5 | The dot whose tray is open at first, as its index in spots; -1 for none. |
defaultRange | number | 30 | The range’s first day at first, counted from start. |
defaultPage | number | 0 | The open tray’s card shown at first, newest first (0 is the newest). Given, it turns the demo off. |
defaultPeriod | 0 | 30 | 60 | 0 | The select’s period at first, in days before the end; 0 is All time. Given, it turns the demo off. |
defaultRanging | boolean | false | Opens with the range drawn on the plot and the dots outside it faded, held until the range moves. Given, it turns the demo off. |
onCompare | (version: Version) => void | — | Called with the version whose Compare button is pressed. |
onDetails | (version: Version) => void | — | Called with the version whose Details button is pressed. |
autoplay | boolean | true | While on screen and untouched, a demo pointer hovers dots, pages through versions and drags the range. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type ChangeEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { ChatMark, CheckCircle, GearIcon, ModelMark } from './icons';import { PEOPLE, SPOTS, type Spot, type Version } from './versions';import { useAutoplay } from './use-autoplay';import styles from './version-history.module.css';/* * Version history: a workflow editor's timeline of saved versions, under its canvas./* Version history (version-history.tsx): an editor window, its side bar, its canvas, and the Version History panel docked at the bottom of the canvas. Sizes are in px for a window 830 wide and 600 tall; narrower than 600, the side bar goes and the timeline packs tighter. Memory. The window is the one layout boundary, of a fixed height. A phone keeps the page's pixels in tiles the width of the screen, and a tile painted again gets a second buffer, so what changes while the demo plays paints on small layers of its own: the tray, the dots' field, the range's row, the months and the line for screen readers. What moves does it with transforms on boxes of a fixed size, and only the cards next to the one on show exist, so a page turn moves a few cards rather than a strip of all of them. The drawings are SVGs that never change; the avatars and the timeline's marks are HTML boxes. Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that rewrites light-dark() for older browsers leaves as written; without light-dark(), the light colours stay. The avatars and the chat tool's mark keep their colours. */.stage { /* The window and its edge; the side bar's gear. */ --window: #fff; --window-edge: rgb(0 0 0 / 6%); --gear: #8f8f8f; /* The canvas, its edge, its dotted guides, the wire and its port. *//** Someone who saved versions; their illustrated avatar is drawn in the stylesheet. */export type Person = 'theo' | 'maya' | 'ines' | 'ravi';export const PEOPLE: Record<Person, string> = { theo: 'Theo', maya: 'Maya', ines: 'Ines', ravi: 'Ravi',};/** A saved version of the workflow. */export type Version = { /** Its number, counted from the workflow's first version. */ number: number; /** When it was saved, in minutes after midnight. */ time: number; /** What changed, in a sentence. */ note: string; /** Who made it. */ authors: Person[]; /** How much it changed, in edited fields: the dot grows with it. */ changes: number; /** It went live. */ published?: boolean;/* * The canvas' drawings: the settings gear, the model's mark, the chat tool's mark and the check * in the Output tag. They never change once drawn (the timeline's own marks are HTML boxes, so * that nothing inside an SVG is ever restyled while the demo plays). */export function GearIcon({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 16 16" aria-hidden="true"> <path d="M6.7 1.9h2.6l.4 1.7c.4.2.8.4 1.1.7l1.7-.5 1.3 2.2-1.3 1.2a4 4 0 0 1 0 1.3l1.3 1.2-1.3 2.2-1.7-.5c-.3.3-.7.5-1.1.7l-.4 1.7H6.7l-.4-1.7a4 4 0 0 1-1.1-.7l-1.7.5-1.3-2.2 1.3-1.2a4 4 0 0 1 0-1.3L2.2 6l1.3-2.2 1.7.5c.3-.3.7-.5 1.1-.7z" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" /> <circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" strokeWidth="1.2" /> </svg> );}/** The model's mark: a four-pointed spark in a ring. */export function ModelMark({ className }: { className?: string }) { return ('use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a dot or a button and press it, or grab the range and drag it, calling * the component's own handlers as a hand would. Stopping the demo (the reader takes over, or it * scrolls away) ends the step in progress at once: its promise rejects, which ends the script * right there, and the pointer fades out. * * Nothing the pointer does needs a layout: it moves by animations the compositor plays (a drag * too: the dragged value follows that animation's clock), the press scales a box that keeps a * transform at rest, and the arrow and the pointing hand are HTML boxes around drawings that * never change, swapped by their opacity (the stylesheet's rule, from `data-hand`). */export type Point = { x: number; y: number };/** The arrow, or the pointing hand over something that can be pressed. */export type Glyph = 'arrow' | 'hand';export type Demo = { /** Resolves after `ms`. */'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;