npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@defaultusr/vertical-timeline

v1.3.0

Published

Canvas-rendered vertical timeline with rich HTML event cards, storyline groups, bands and a minimap

Readme

@defaultusr/vertical-timeline

[!WARNING]
The entire repo was generated 100% with Claude Code and has only received a very brief review. Use at your own risk.

A vertical timeline rendered on canvas, with rich HTML cards linked to their dates.

  • Handles thousands of events. Event dots and bars, grid, bands, markers and connectors are drawn on canvas; only the cards near the viewport exist in the DOM.
  • Rich cards: your own HTML, or a React, Vue or Svelte component, placed next to the event's date. Cards move aside for each other, shrink or hide as you zoom out, and ease into new positions instead of jumping.
  • Momentary and spanning events, background bands, markers (labelled lines at a moment), a minimap, and storylines: sets of connected events drawn as rails that reuse lanes over time.
  • Scroll, drag with inertia, and zoom with pinch or ctrl/⌘ + wheel; click to select; keyboard support.
  • Light and dark themes via CSS variables, including the canvas colours.
  • Dates in any language: axis labels and default cards use Intl, so month and weekday names, date order, 12- or 24-hour clock and digits follow the locale.
┌─────────┬──────────────────────┬───┬───────────────────────────┐
│ minimap │ axis  main  │ story- │ ╲ │  ┌─ card (your HTML) ───┐ │
│         │       lane  │ lines  │  ●──┤                      │ │
│         │ bands, spans, markers│   │  └──────────────────────┘ │
└─────────┴──────────────────────┴───┴───────────────────────────┘
            canvas                connectors      DOM cards

Playground → (deployed from main by GitHub Actions; see Releasing)

Install

npm install @defaultusr/vertical-timeline

It ships as an ES module with TypeScript types. It has one dependency, d3-scale, used for time ticks. It injects its own base styles, so no CSS import is needed.

Quick start

import { VerticalTimeline } from '@defaultusr/vertical-timeline';

const timeline = new VerticalTimeline(document.getElementById('timeline')!, {
  storylines: [{ id: 'ww2', title: 'World War II', color: '#8b5fb0' }],
  items: [
    { id: 'poland', start: new Date(1939, 8, 1), title: 'Invasion of Poland', storyline: 'ww2', priority: 10 },
    { id: 'blitz', start: new Date(1940, 8, 7), end: new Date(1941, 4, 11), title: 'The Blitz', storyline: 'ww2' },
    { id: 'penicillin', start: new Date(1928, 8, 28), title: 'Penicillin discovered', color: '#d64545' },
  ],
  bands: [{ start: new Date(1929, 9, 1), end: new Date(1939, 0, 1), color: 'rgba(0,0,0,.05)', label: 'Great Depression' }],
  renderCard(item, el, lod) {
    const title = document.createElement('div');
    title.className = 'vt-card__title';
    title.textContent = item.title;
    el.append(title);
    if (lod === 'full') el.append(describe(item)); // your richer content
  },
});

timeline.on('select', (id) => console.log('selected', id));
timeline.setWindow(new Date(1935, 0, 1), new Date(1946, 0, 1));

The container needs a height (the timeline fills it), e.g. #timeline { height: 100vh }.

Concepts

Events (items) are momentary (only start) or spanning (start and end). A momentary event is a dot; a spanning event is a bar in a lane beside the dots. Bars are never shorter than a dot, so short spans stay visible when zoomed out (a short bar is centred on the middle of its range). Every event can have a card.

Storylines (storylines) are sets of connected events, such as "World War II" or "The French Revolution". A storyline is drawn as a rail from its first event to its last, with its title running down the rail. Storylines are packed into shared lanes by time: once one ends, a later one can take its lane. Events without a storyline go in the main lane on the left, which spans all time. Clicking a storyline's title zooms to it.

Bands are coloured background ranges (eras, recessions, etc.) with an optional label.

Markers are labelled dashed lines at a moment, such as "Lehman Brothers collapses" or "Today". A marker line starts at its own lane, at the line the lane's event dots sit on, and spans the full width to the right, behind the cards too. Its own lane is its storyline's lane if it's assigned to one, otherwise the main lane. Its label sits centred on the line, right-aligned just before the cards like band labels; a long label may extend left past the line's start, over other lanes, as far as the axis. A marker assigned to a storyline also takes the storyline's colour, hides with it, and counts toward its time extent (the rail and "zoom to storyline" include it). Labels are drawn on top of everything; clicking one fires markerclick.

markers: [
  { id: 'lehman', at: new Date(2008, 8, 15), label: 'Lehman Brothers collapses', storyline: 'gfc' },
  { id: 'today', at: Date.now(), label: 'Today' },
]

Cards are placed as close to their date as crowding allows, joined to it by a connector. As you zoom out, lower-priority cards shrink to compact and then disappear, leaving just the dot. Hovering such an event shows a tooltip with its date and title (renderTooltip to customize). Clicking an event (or calling select(id)) always shows its card in full.

Input | | | |---|---| | Wheel / trackpad scroll, drag | Pan (drag has inertia) | | Ctrl/⌘ + wheel, trackpad pinch, two-finger pinch | Zoom around the pointer | | ↑ ↓, Page Up / Down, + / - | Pan / zoom (when the timeline has focus) | | Click an event dot or bar, or a card | Select it; click again, click empty space, or press Esc to clear | | Click a storyline title | Zoom to the storyline | | Click a marker label | Fires markerclick | | Click or drag in the minimap | Jump / scroll |

API

new VerticalTimeline(container, options?)

| Option | Type | Default | | |---|---|---|---| | items | TimelineItem[] | [] | Events. | | storylines | TimelineStoryline[] | [] | Storylines. | | bands | TimelineBand[] | [] | Background ranges. | | markers | TimelineMarker[] | [] | Labelled dashed lines at a moment; see Concepts. | | renderCard | RenderCard | title + date | Renders a card's content; see Cards. | | renderTooltip | RenderTooltip | date + title | Renders the tooltip of a hovered event that has no card; see Cards. | | colorScheme | 'auto' \| 'light' \| 'dark' | 'auto' | auto follows the OS setting. | | locale | string \| string[] | browser language | BCP 47 tag(s) for dates on the axis and default cards, e.g. 'de-DE'; see Localization. | | formatTick | (date, unit) => string | locale default | Custom axis labels; see Localization. | | theme | Partial<TimelineTheme> | from CSS | Canvas colour overrides; see Styling. | | cardDensity | number | 1.25 | How many cards to show at a given zoom. 1 = cards never overlap at their dates; higher shows more cards and lets them push each other further. | | maxDisplacement | number | 0.5 | Furthest a card may be pushed from its date, in viewport heights, before it shrinks or hides. | | cardGap | number | 8 | Vertical gap between cards, px. | | anchorOffset | number | 18 | Distance from a card's top to where its connector attaches, px. | | estimatedFullHeight | number | 120 | Card height guess used before a card is measured, px. Per item: estimatedHeight. | | estimatedCompactHeight | number | 34 | Same, for compact cards. | | minimapWidth | number | 56 | px | | axisWidth | number \| 'auto' | 'auto' | Width of the date axis, px. 'auto' fits the locale's widest label. | | gutterWidth | number | 72 | Space for connectors between track and cards, px. | | minMsPerPx | number | 1000 | Zoom-in limit, milliseconds per pixel. |

The view initially fits all events.

Data types

type TimeInput = Date | number;            // number = ms since epoch

interface TimelineItem {
  id: string;
  start: TimeInput;
  end?: TimeInput;          // present → spanning event
  title: string;
  storyline?: string;       // storyline id; missing or unknown → main lane
  color?: string;           // this event's colour (dot, bar, connector, card accent);
                            // overrides the storyline colour. Any canvas colour string.
  priority?: number;        // default 0; higher keeps its card longer when zooming out
  estimatedHeight?: number; // full card height hint, px
  data?: unknown;           // anything; passed through to renderCard
}

interface TimelineStoryline {
  id: string;
  title: string;
  color?: string;           // default colour of the storyline's events
  visible?: boolean;        // default true
}

interface TimelineMarker {
  id: string;
  at: TimeInput;            // the moment
  label?: string;
  storyline?: string;       // belongs to this storyline: its colour, hidden with it, part of its extent
  color?: string;           // default: the storyline's colour, else --vt-accent
}

interface TimelineBand {
  start: TimeInput;
  end: TimeInput;
  color: string;
  label?: string;
}

Methods

| Method | | |---|---| | setItems(items) | Replace all events. Keeps the current view. | | setStorylines(storylines) | Replace storylines; events are re-assigned by their storyline id. | | setBands(bands) | Replace bands. | | setMarkers(markers) | Replace markers. Storyline markers can change storyline extents, and so how lanes are shared. | | setWindow(start, end) | Show the range [start, end] across the viewport. | | select(id \| null) | Select an event: its card is shown full even if zoom or crowding hid it, and the view scrolls to it if it's off screen. null clears. Unknown ids and events of hidden storylines are ignored. | | selected | The selected id, or null (getter). | | focusStoryline(id) | Zoom to fit a storyline. | | setStorylineVisible(id, visible) / isStorylineVisible(id) | Hide or show a storyline's events and cards. A hidden storyline frees its lane. Hiding the selected event's storyline clears the selection. | | setColorScheme('auto' \| 'light' \| 'dark') | Switch the palette. | | setLocale(locale?) | Switch the date language; mounted cards are re-rendered. undefined = the browser's language. | | locale | The resolved locale in use, e.g. 'de-DE' (getter). | | refreshTheme() | Re-read canvas colours from CSS variables. Needed only when you change the variables yourself (the colour scheme, the OS setting and first attachment to the document are handled). | | on(event, fn) | Subscribe; returns an unsubscribe function. | | destroy() | Remove the timeline and all listeners. | | viewport | Low-level view state (t0, t1, msPerPx, timeToY(), zoomAt(), panBy(), …). Call invalidate() after changing it. |

Events

| Event | Payload | | |---|---|---| | select | string \| null | Selection changed (click, select(), or the selected event's storyline was hidden). | | hover | string \| null | Pointer entered or left an event's dot, bar or card. | | rangechange | { start: number; end: number } | The visible range changed (ms). Fires once per frame at most. | | markerclick | string | A marker's label was clicked; payload is the marker id. |

Cards

type CardLod = 'full' | 'compact';
type RenderCard = (item: TimelineItem, el: HTMLElement, lod: CardLod) => void | (() => void);

renderCard fills el, the card element, with content. It's called when a card is mounted and again whenever its lod changes. If it returns a function, that function is called before the next render and when the card is unmounted. Use it to unmount framework components:

// React
renderCard(item, el, lod) {
  const root = createRoot(el);
  root.render(<EventCard item={item} compact={lod === 'compact'} />);
  return () => root.unmount();
}

renderTooltip(item, el) works the same way for the tooltip shown when hovering an event whose card isn't shown: fill el, optionally return a cleanup function (called when the tooltip moves to another event or hides). The tooltip has the class vt-tooltip, the card variables (--vt-card-bg, --vt-card-border, …) and --vt-item-color, and ignores pointer events.

Cards are virtualized: only cards near the viewport exist, and elements are reused. Keep per-card state in your own store rather than in the element. Card height can be anything, and changes (e.g. images loading) are picked up automatically. Clicks on links, buttons and inputs inside a card work normally and don't select the card.

Localization

Axis labels and the default card's dates are formatted with Intl.DateTimeFormat, so any locale the browser supports works with no extra data. That covers month and weekday names, date order ("Feb 7", "7. Feb.", "2月7日"), 12- or 24-hour time and digits (e.g. Arabic-Indic).

const timeline = new VerticalTimeline(el, { items, locale: 'fr-FR' });
timeline.setLocale('ja-JP');     // switch at runtime
timeline.setLocale(undefined);   // back to the browser's language

Each axis tick is labelled by the largest calendar boundary it falls on (TickUnit): a year boundary shows the year, a month boundary the month name, and so on. Day ticks show a weekday ("Sat 7"), or a date ("Feb 7") when ticks are a week or more apart ('week'). To take over the labels entirely:

type TickUnit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond';

new VerticalTimeline(el, {
  items,
  formatTick: (date, unit) =>
    unit === 'year' ? `’${String(date.getFullYear()).slice(2)}` : date.toLocaleDateString('pt-BR', { month: 'short' }),
});

With axisWidth: 'auto' (the default) the axis is as wide as the locale's (or your formatTick's) widest label at any zoom level, so it doesn't change width while zooming.

Your own renderCard can use the same language via timeline.locale. setLocale() re-renders mounted cards, so they pick up the change. Band labels are upper-cased with the locale's rules (e.g. Turkish dotted İ). Storyline titles, band labels and card content are your text, so translate them yourself.

Dates are placed and labelled in the browser's local time zone.

Styling

All colours are CSS custom properties on .vt-root, including the canvas colours. The timeline reads them at startup and whenever the colour scheme changes. The built-in defaults have zero specificity, so a plain rule overrides them:

.vt-root {
  --vt-accent: #0a7cff;
  --vt-card-radius: 4px;
}
/* dark values, if you customise colours */
.vt-root[data-theme="dark"] { --vt-accent: #5aa6ff; }
@media (prefers-color-scheme: dark) {
  .vt-root:not([data-theme="light"]) { --vt-accent: #5aa6ff; }
}

| Variable | Used for | |---|---| | --vt-font | font shorthand for cards; the canvas uses its family | | --vt-bg | Background (timeline, minimap, hollow dots) | | --vt-accent | Default event colour (dots, bars, connectors, card accent) | | --vt-axis-text | Axis labels, band labels | | --vt-grid-major, --vt-grid-minor | Grid lines | | --vt-track | Main lane line | | --vt-minimap-density, --vt-minimap-viewport | Minimap plot and its viewport window | | --vt-card-bg, --vt-card-border, --vt-card-text, --vt-card-muted | Cards | | --vt-card-radius, --vt-card-shadow | Cards (shadow on hover / selection) |

Dark mode: colorScheme: 'auto' (the default) follows prefers-color-scheme, and 'light' / 'dark' force a palette by setting data-theme on .vt-root. If your app switches themes some other way (e.g. a class on <html> that changes these variables), call timeline.refreshTheme() afterwards so the canvas picks up the new colours.

Per-event colour: set color on an item. It colours the event's dot or bar, its connector, and its card: the left border, plus the --vt-item-color variable, which your card content can use (e.g. color: var(--vt-item-color)). If color is missing, the storyline's colour is used, then --vt-accent.

Class names for styling card content and state:

| Selector | | |---|---| | .vt-card | Every card (also [data-id="<item id>"]) | | .vt-card--full, .vt-card--compact | Level of detail | | .vt-card--hover, .vt-card--selected | State | | .vt-card__date, .vt-card__title | Parts of the default card (and tooltip); reusable in your own | | .vt-tooltip | Tooltip of a hovered event without a card | | .vt-root, .vt-minimap, .vt-body, .vt-cards | Layout containers |

theme (constructor option) overrides canvas colours from JavaScript instead of CSS: background, axisText, gridMajor, gridMinor, track, item, minimapDensity, minimapViewport, font.

Development

npm install
npm run dev          # playground at http://localhost:5173 (index.html + demo/)
npm test             # unit tests (vitest)
npm run typecheck
npm run build        # dist/vertical-timeline.js + dist/types
npm run build:demo   # static playground in site/

Releasing

Two GitHub Actions workflows live in .github/workflows/:

  • release.yml runs when a v* tag is pushed. It checks that the tag matches package.json, runs typecheck, tests and build, then npm publish --provenance and creates a GitHub release with generated notes. Prerelease versions (1.2.0-beta.1) are published under the next dist-tag.
    npm version minor        # bumps package.json and tags vX.Y.Z
    git push --follow-tags
    It authenticates with npm trusted publishing (OIDC), so there's no token or secret. One-time setup on npmjs.com: package settings → Trusted publisher → GitHub Actions, with owner thedefaultusr, repository vertical-timeline and workflow release.yml.
  • pages.yml runs on every push to main. It runs the tests, builds the playground and deploys it to GitHub Pages. One-time setup: Settings → Pages → Source: GitHub Actions.

How it works

Per animation frame, in this order (src/Timeline.ts):

  1. Viewport (core/Viewport.ts): the only time ↔ y mapping. There is no native scroll anywhere; wheel, drag, pinch, keys and inertia all go through it (interaction/Gestures.ts), so DOM and canvases always come from the same frame.
  2. Card selection: items within ±1 viewport, filtered by precomputed per-item reveal thresholds (cards/reveal.ts). Each item is revealed once the zoom gives it one card height of room from the nearest more important item; those neighbours are found for all items in O(n log n) with two monotonic-stack passes over the time-sorted items. The thresholds only change with zoom, so cards don't flicker. Each card is full, compact or hidden (dot only).
  3. Dodge (cards/dodge.ts): an O(n) block-merge 1-D layout. It keeps the cards in order and stops them overlapping, while keeping the total squared distance from their dates as small as possible. The output changes continuously with the input, so cards glide during zoom.
    • Edges: cards must stay within the scrollable range: from the top of the view when scrolled all the way up to its bottom when scrolled all the way down, at the current zoom. In the middle of the timeline a card past the edge of the view is a scroll away, so this only binds near the start and end (or when zoomed out so far there's little to scroll), keeping every card reachable. The range is fixed in timeline coordinates and doesn't depend on card dates, so it never flips on or off while scrolling. A merged block is clamped to its members' combined bounds, which keeps the layout optimal. If a group of cards can't fit, the budget shrinks or drops cards; a group must have room to spare before a card grows back.
    • Budget (cards/budget.ts): cards pushed further than maxDisplacement are demoted. In each crowded cluster of touching cards, the least important card gives way: it shrinks from full to compact, then is dropped. The order is: pinned spans (their anchor is approximate), then cards that weren't shown last frame, then shown cards dated off screen, then shown cards dated on screen. Priority decides within each tier. So scrolling never removes a card whose date is on screen. Cards being promoted must fit within 70% of the limit, which stops cards near the limit from flipping every frame.
    • Selection: the selected item is always a candidate, always full, and never demoted (forced), so clicking any event shows its card even when zoom or crowding hid it; neighbours make room with eased motion.
    • Easing jumps: the layout only snaps when the card sequence changes: a point's date passes a span's card pinned near the top, a card mounts, or a card is resized. On those frames the previous sequence is laid out again at the current anchors, and the difference from the new layout becomes a per-card offset that decays (τ = 100ms). Cards sliding up go behind the others (z-index 0), and cards sliding down go in front (z-index 2).
  4. Cards (cards/CardLayer.ts): virtualized and pooled DOM. New cards are measured in one batch, and later size changes are picked up by ResizeObserver. Cards are positioned only with transform.
  5. Storylines (layout/storylines.ts): a storyline is a set of connected events, such as "World War II". It's drawn as a rail from its first event or marker to its last, with its events on the rail, its spanning events in lanes beside it, and its title running down the rail. The title stays visible while the rail is on screen, and clicking it zooms to fit the storyline. Storylines are packed into shared lanes by time: a storyline takes the first lane whose previous storyline has already ended, so a lane is reused once a storyline is over. Events without a storyline sit in the main lane on the left, which spans all time. Hiding a storyline frees its lane; it only re-runs the lane packing and reveal thresholds, so items and measured card heights are kept. Items take their storyline's colour.
  6. Canvases: axis (d3-scale picks tick positions; labels come from core/dateFormat.ts), grid, bands, storyline rails, markers, spans and points (render/TimelineRenderer.ts; event dots and bars are batched into one path per colour, marker labels are drawn last, on top); connectors (render/ConnectorRenderer.ts); and the minimap, whose density plot is cached and redrawn only when data or size changes (render/Minimap.ts). Colours come from the CSS variables (styles.ts). Canvases are re-sized when devicePixelRatio changes (moving to another display), via a resolution media query.

Not done yet

  • Nested storylines; keeping a storyline's lane stable when others are hidden or added
  • Mounting cards during idle time, with skeletons while flinging (zoom-in frames currently spike to ~10–20ms when many cards change level of detail at once)
  • Clusters (+12) on the track when zoomed out
  • Hidden DOM copy of the timeline for screen readers, and keyboard focus for items
  • Editing (drag to move / resize)

License

MIT