Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Version history

New

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.tsx
Live · interactive

Version History

Version 53

Oct 10, 10:35 AM

Refined Quillo node parameters and updated message formatting.

Version 52

Oct 10, 10:21 AM

Adjusted the summary prompt and message spacing for long threads.

Version 51

Oct 10, 10:15 AM

Added a retry step for when the channel is busy.

30 Days

The 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

  • An editor whose saves pile up for months, where when someone worked matters as much as what changed.
  • Bursts of saves: versions made within the hour or so share one dot, and the tray pages through them.
  • Spotting what went live: published versions are green dots among the grey ones.
  • Picking a version to compare or inspect, with its note, its authors and its time in one card.

When not to use

  • A handful of versions. A plain list, newest first, reads faster than a plot.
  • Line-by-line changes. Use a diff view; this only opens one from Compare.
  • Undo within a session. Use undo and redo, not saved versions.
  • Phones as the main place it lives: the dots are small targets and the tray covers the plot.
  • Events with no time of day, such as releases by date. Use a timeline or a calendar.

You can add this component with the shadcn CLI or by hand:

Into components/version-history of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/version-history.json

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.

Usage

workflow-history.tsx
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.

  • Each dot is a day and its versions. It sits at its date and at its mean time of day between 6 AM and 10 PM, takes its size from the total number of changed fields, and is green when one of its versions was published.
  • The tray shows one card per version, newest first. Only four cards are mounted: the one on show, the one before it and the next two. Each is placed by translateX, its distance from the page on show times a card plus the gap, and the transform is transitioned, so Back and Next slide the cards.
  • The tray is placed with the CSS translate property, from the dot’s position and kept inside the panel. It is a layout boundary of a set size, so opening another dot lays out the tray alone, and its clipping viewport is a layer of its own, so cards sliding under it never resize the tray’s layer.
  • The range rides a carrier as wide as the track, translated by its first day. A drag writes the fractional day to a CSS variable on each frame without re-rendering, and the state snaps to whole days. The frosted handle shows the track’s ticks softly from edge to edge, offset to stay on them, with a clear margin around its label, and while the range moves a band marks it on the plot and the dots outside it fade back.
  • The month under the range’s middle turns bold. The select keeps all versions, or only the last 60 or 30 days. The demo hovers three dots, pages forward and back, then drags the range a month to the right and back.
  • Every colour of the window, its panel, plot, tray, cards and scrubber is a light-dark() token, so it follows the page’s color-scheme, or the colorScheme prop; the avatars and the chat tool’s mark keep their colours.
PropTypeDefaultDescription
spotsSpot[]SPOTSThe dots: each is a day and the versions saved then, oldest first.
startstring'2025-09-01'The timeline’s first day, as “YYYY-MM-DD”.
daysnumber93How many days the timeline shows.
spannumber30How many days the range covers.
defaultSpotnumber5The dot whose tray is open at first, as its index in spots; -1 for none.
defaultRangenumber30The range’s first day at first, counted from start.
defaultPagenumber0The open tray’s card shown at first, newest first (0 is the newest). Given, it turns the demo off.
defaultPeriod0 | 30 | 600The select’s period at first, in days before the end; 0 is All time. Given, it turns the demo off.
defaultRangingbooleanfalseOpens 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.
autoplaybooleantrueWhile 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.
classNamestring—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.

version-history.tsx
// 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.module.css
/* 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. */
versions.ts
/** 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;
icons.tsx
/* * 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 (
demo-pointer.tsx
'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-autoplay.ts
'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;
  • The dots are buttons with full labels: the date, how many versions, the latest version and its time, and “published” where it applies. They form one tab stop; the arrow keys, Home and End move between them and open each tray. They carry aria-expanded and aria-controls, and Escape closes the tray and brings focus back to its dot.
  • The tray is a labelled group. Only the card on show can be reached, because the others are inert. Back and Next are disabled at the ends, and the reader’s page turns are announced.
  • The range is a role="slider" whose value text reads like “Oct 1 to Oct 30”. The arrow keys move it a day, Page Up and Page Down a week, and Home and End to the ends; the ‹ › buttons are labelled and step a week, and the select is labelled “Period”. The reader’s steps with those buttons and changes of period are announced politely.
  • Focus rings are visible throughout. Under reduced motion every change is immediate and there is no demo.