{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "type": "registry:block",
  "title": "Scroll Timeline",
  "description": "A changelog timeline whose line draws itself as you scroll, on a spring, with a glowing head that swells with scroll speed. Each node fills and pops when the head reaches it, and its entry blur-rises in piece by piece; scroll back and it all rewinds.",
  "docs": "Scroll Timeline was added to public/vividsites/timeline.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/timeline",
  "categories": [
    "ui",
    "data-display"
  ],
  "files": [
    {
      "path": "components/vividsites/timeline.html",
      "type": "registry:file",
      "target": "public/vividsites/timeline.html",
      "content": "<!--\nvs-timeline: a vertical changelog whose line draws itself as you scroll.\n  A hairline rail runs through every entry; a bright line fills it down to a reading line\n  (55% of the viewport by default). The fill follows scroll on a spring and ends in a glowing\n  head whose glow swells with scroll speed. When the head reaches an entry, its node fills and\n  pops on a spring, and the entry blur-rises in element by element; scroll back up and it all\n  rewinds. The entry the head last passed is marked current.\n  Works with window scroll or the nearest scrollable ancestor. Without JS every entry is\n  simply visible.\n\n  Markup: an ordered list, newest first.\n    <ol class=\"vs-timeline\" data-vs-timeline-layout=\"alternate\" aria-label=\"Changelog\">\n      <li class=\"vs-timeline-item\">\n        <div class=\"vs-timeline-meta\">\n          <time class=\"vs-timeline-date\" datetime=\"2026-09-18\">18 Sep</time>\n          <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v4.2</span></span>\n        </div>\n        <article class=\"vs-timeline-card\">\n          <h3 class=\"vs-timeline-title\">Offline maps</h3>\n          <p class=\"vs-timeline-body\">…</p>\n          (optional) <div class=\"vs-timeline-keys\"><kbd>⌘</kbd><kbd>K</kbd> …</div>\n          (optional) <pre class=\"vs-timeline-diff\"><span data-d=\"-\">…</span><span data-d=\"+\">…</span></pre>\n          <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li><li data-tag=\"improved\">Improved</li><li data-tag=\"fixed\">Fixed</li></ul>\n        </article>\n      </li>\n    </ol>\n  Root attributes:\n    data-vs-timeline-layout   \"left\" (dates in a column left of the rail) | \"alternate\" (centred rail,\n                              entries swap sides). Narrower than 640px it is always a single column.\n    data-vs-timeline-line     \"solid\" (default) | \"dashed\"\n    data-vs-timeline-theme    \"dark\" (default) | \"light\"\n    data-vs-timeline-focus    reading line as a fraction of the viewport height (default 0.55)\n  CSS variables: accent, ink, muted, rail, surface, bg (fills the node hole: match your page),\n    font, display font, aside width, gutter, radius. See the top of the style block.\n    The root paints --vs-timeline-bg behind itself; set --vs-timeline-paint: transparent to let your\n    page show through (then set --vs-timeline-bg to your page colour for the node holes).\n  JS: el.vsTimeline.refresh() after changing entries.  Event: \"timelinestep\" {index} when the\n    current entry changes.\n  Reduced motion: the finished state, all entries shown, the line fully drawn.\n-->\n<style>\n.vs-timeline {\n  --vs-timeline-accent: #8fe3bf;\n  --vs-timeline-font: inherit;                  /* body copy */\n  --vs-timeline-display: inherit;               /* dates and titles (the demo uses Fraunces) */\n  --vs-timeline-mono: ui-monospace, \"SF Mono\", \"Cascadia Code\", Menlo, monospace;\n  --vs-timeline-bg: #0d1411;                    /* page colour, shows through the node rings */\n  --vs-timeline-ink: #e8f0eb;\n  --vs-timeline-muted: rgba(232,240,235,.6);\n  --vs-timeline-rail: rgba(232,240,235,.13);\n  --vs-timeline-surface: rgba(255,255,255,.028);\n  --vs-timeline-line-ink: var(--vs-timeline-accent);   /* the drawn line; darker on light themes */\n  --vs-timeline-radius: 18px;\n  --vs-timeline-aside: 168px;                   /* date column in the \"left\" layout */\n  --vs-timeline-gutter: 76px;                   /* space the rail sits in */\n  --vs-timeline-node-y: 40px;                   /* node centre from the top of an entry */\n\n  position: relative; list-style: none; margin: 0; padding: 56px 24px;\n  display: grid; row-gap: 44px; container-type: inline-size;\n  background: var(--vs-timeline-paint, var(--vs-timeline-bg));   /* set --vs-timeline-paint: transparent to show your page */\n  font-family: var(--vs-timeline-font); color: var(--vs-timeline-ink);\n  -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums;\n}\n.vs-timeline *, .vs-timeline *::before, .vs-timeline *::after { box-sizing: border-box; }\n.vs-timeline[data-vs-timeline-theme=\"light\"] {\n  --vs-timeline-bg: #f4f1ea; --vs-timeline-ink: #17201b; --vs-timeline-muted: rgba(23,32,27,.62);\n  --vs-timeline-rail: rgba(23,32,27,.14); --vs-timeline-surface: rgba(255,255,255,.62);\n  --vs-timeline-line-ink: color-mix(in srgb, var(--vs-timeline-accent) 52%, #0d1f16);\n}\n\n/* ---- entry grid: [meta] [rail] [card] ---- */\n.vs-timeline-item { position: relative; display: grid; grid-template-columns: var(--vs-timeline-aside) var(--vs-timeline-gutter) minmax(0, 1fr); align-items: start; }\n.vs-timeline-meta { grid-column: 1; grid-row: 1; text-align: right; padding-top: 22px; }\n.vs-timeline-node { grid-column: 2; grid-row: 1; }\n.vs-timeline-card { grid-column: 3; grid-row: 1; }\n.vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item { grid-template-columns: minmax(0, 1fr) var(--vs-timeline-gutter) minmax(0, 1fr); }\n.vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item:nth-child(even) .vs-timeline-meta { grid-column: 3; text-align: left; }\n.vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item:nth-child(even) .vs-timeline-card { grid-column: 1; }\n\n/* the rail, the drawn line and its head (added by the script) */\n.vs-timeline-rail, .vs-timeline-fill, .vs-timeline-head { position: absolute; left: calc(var(--vs-timeline-aside) + var(--vs-timeline-gutter) / 2); pointer-events: none; }\n.vs-timeline[data-vs-timeline-layout=\"alternate\"] :is(.vs-timeline-rail, .vs-timeline-fill, .vs-timeline-head) { left: 50%; }\n.vs-timeline-rail {\n  top: 0; width: 1px; margin-left: -.5px; background: var(--vs-timeline-rail);\n  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 40px, #000 calc(100% - 90px), transparent);\n  mask-image: linear-gradient(to bottom, transparent, #000 40px, #000 calc(100% - 90px), transparent);\n}\n.vs-timeline-fill {\n  width: 2px; margin-left: -1px; border-radius: 2px;\n  background: linear-gradient(to bottom, color-mix(in srgb, var(--vs-timeline-line-ink) 30%, transparent), var(--vs-timeline-line-ink) 60%);\n  box-shadow: 0 0 10px color-mix(in srgb, var(--vs-timeline-accent) 45%, transparent);\n  will-change: clip-path;\n}\n.vs-timeline[data-vs-timeline-line=\"dashed\"] .vs-timeline-rail { background: repeating-linear-gradient(to bottom, var(--vs-timeline-rail) 0 5px, transparent 5px 10px); width: 1.5px; margin-left: -.75px; }\n.vs-timeline[data-vs-timeline-line=\"dashed\"] .vs-timeline-fill {\n  -webkit-mask-image: repeating-linear-gradient(to bottom, #000 0 7px, transparent 7px 12px);\n  mask-image: repeating-linear-gradient(to bottom, #000 0 7px, transparent 7px 12px); box-shadow: none;\n}\n.vs-timeline-head { top: 0; width: 0; height: 0; z-index: 2; will-change: transform; opacity: 0; transition: opacity .3s; }\n.vs-timeline-head.is-on { opacity: 1; }\n.vs-timeline-head b { position: absolute; left: -4px; top: -4px; width: 8px; height: 8px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 2px var(--vs-timeline-accent), 0 0 12px 2px var(--vs-timeline-accent); }\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-head b { box-shadow: 0 0 0 2px var(--vs-timeline-line-ink), 0 0 12px 2px color-mix(in srgb, var(--vs-timeline-accent) 80%, transparent); }\n.vs-timeline-head i { position: absolute; left: -40px; top: -40px; width: 80px; height: 80px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--vs-timeline-accent) 55%, transparent), color-mix(in srgb, var(--vs-timeline-accent) 14%, transparent) 55%, transparent); will-change: transform, opacity; }\n.vs-timeline-head s { position: absolute; left: -1px; bottom: 0; width: 2px; height: 110px; border-radius: 2px; background: linear-gradient(to bottom, transparent, color-mix(in srgb, #fff 55%, var(--vs-timeline-accent))); opacity: .8; will-change: transform; transform-origin: 50% 100%; }\n\n/* nodes */\n.vs-timeline-node { position: relative; justify-self: center; width: 16px; height: 16px; margin-top: calc(var(--vs-timeline-node-y) - 8px); z-index: 1; will-change: transform; }\n.vs-timeline-node::before { content: \"\"; position: absolute; inset: 0; border-radius: 50%; background: var(--vs-timeline-bg); box-shadow: inset 0 0 0 1.5px var(--vs-timeline-rail), 0 0 0 5px var(--vs-timeline-bg); transition: box-shadow .35s cubic-bezier(.2,0,0,1); }\n.vs-timeline-node i { position: absolute; inset: 4px; border-radius: 50%; background: var(--vs-timeline-line-ink); transform: scale(0); transition: transform .45s cubic-bezier(.34,1.8,.5,1); }\n.vs-timeline-node::after { content: \"\"; position: absolute; inset: -7px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in srgb, var(--vs-timeline-accent) 55%, transparent); opacity: 0; transform: scale(.6); transition: opacity .5s, transform .6s cubic-bezier(.16,1,.3,1); }\n.vs-timeline-item.is-on .vs-timeline-node::before { box-shadow: inset 0 0 0 1.5px var(--vs-timeline-line-ink), 0 0 0 5px var(--vs-timeline-bg), 0 0 18px 4px color-mix(in srgb, var(--vs-timeline-accent) 32%, transparent); }\n.vs-timeline-item.is-on .vs-timeline-node i { transform: scale(1); }\n.vs-timeline-item.is-current .vs-timeline-node::after { opacity: 1; transform: scale(1); }\n\n/* meta: date, year, version */\n.vs-timeline-date { display: block; font-family: var(--vs-timeline-display); font-weight: 340; font-size: 30px; line-height: 1.05; letter-spacing: -.02em; font-optical-sizing: auto; }\n.vs-timeline-sub { display: inline-flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 13px; color: var(--vs-timeline-muted); }\n.vs-timeline-ver {\n  display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border-radius: 999px;\n  font-size: 12px; font-weight: 500; letter-spacing: .02em; color: var(--vs-timeline-line-ink);\n  background: color-mix(in srgb, var(--vs-timeline-accent) 10%, transparent);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-timeline-accent) 38%, transparent);\n}\n\n/* card */\n.vs-timeline-card {\n  position: relative; padding: 22px 26px 24px; border-radius: var(--vs-timeline-radius);\n  background: var(--vs-timeline-surface);\n  box-shadow: 0 0 0 .5px var(--vs-timeline-rail), inset 0 .5px 0 rgba(255,255,255,.06), 0 18px 40px -24px rgba(0,0,0,.6);\n  transition: box-shadow .5s cubic-bezier(.2,0,0,1);\n}\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-card { box-shadow: 0 0 0 .5px var(--vs-timeline-rail), inset 0 .5px 0 #fff, 0 1px 2px rgba(20,30,25,.04), 0 18px 40px -26px rgba(20,30,25,.3); }\n.vs-timeline-item.is-current .vs-timeline-card { box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-timeline-accent) 45%, transparent), inset 0 .5px 0 rgba(255,255,255,.08), 0 18px 50px -20px color-mix(in srgb, var(--vs-timeline-accent) 22%, transparent); }\n.vs-timeline-title { margin: 0; font-family: var(--vs-timeline-display); font-weight: 400; font-size: 25px; line-height: 1.2; letter-spacing: -.015em; font-optical-sizing: auto; }\n.vs-timeline-body { margin: 10px 0 0; font-size: 15px; line-height: 1.62; color: var(--vs-timeline-muted); max-width: 46ch; }\n.vs-timeline-tags { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; margin: 16px 0 0; padding: 0; }\n.vs-timeline-tags li {\n  --c: var(--vs-timeline-muted);\n  display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; border-radius: 6px;\n  font-size: 11px; font-weight: 500; letter-spacing: .09em; text-transform: uppercase;\n  color: var(--c); background: color-mix(in srgb, var(--c) 9%, transparent); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--c) 28%, transparent);\n}\n.vs-timeline-tags li::before { content: \"\"; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }\n.vs-timeline-tags [data-tag=\"new\"] { --c: var(--vs-timeline-line-ink); }\n.vs-timeline-tags [data-tag=\"improved\"] { --c: #9cc8ff; }\n.vs-timeline-tags [data-tag=\"fixed\"] { --c: #f1c27a; }\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-tags [data-tag=\"improved\"] { --c: #2d5f9e; }\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-tags [data-tag=\"fixed\"] { --c: #9a5b00; }\n\n/* optional extras: shortcut keys and a mini diff */\n.vs-timeline-keys { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 14px; font-size: 13px; color: var(--vs-timeline-muted); }\n.vs-timeline-keys kbd {\n  display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 7px; border-radius: 7px;\n  font: 500 13px/1 var(--vs-timeline-font); color: var(--vs-timeline-ink);\n  background: linear-gradient(to bottom, rgba(255,255,255,.09), rgba(255,255,255,.03));\n  box-shadow: inset 0 0 0 .5px var(--vs-timeline-rail), inset 0 -1.5px 0 rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.3);\n}\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-keys kbd { background: linear-gradient(#fff, #f1eee6); box-shadow: inset 0 0 0 .5px rgba(0,0,0,.14), inset 0 -1.5px 0 rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06); }\n.vs-timeline-keys kbd + span { margin-left: 6px; }\n.vs-timeline-diff {\n  margin: 14px 0 0; padding: 12px 0; border-radius: 10px; overflow-x: auto;\n  font: 12.5px/1.75 var(--vs-timeline-mono); background: rgba(0,0,0,.22); box-shadow: inset 0 0 0 .5px var(--vs-timeline-rail);\n}\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-diff { background: rgba(23,32,27,.04); }\n.vs-timeline-diff span { display: block; padding: 0 14px; white-space: pre; color: var(--vs-timeline-muted); }\n.vs-timeline-diff span::before { display: inline-block; width: 1.4em; opacity: .7; }\n.vs-timeline-diff [data-d=\"-\"] { color: #f19a8e; background: rgba(241,110,90,.08); }\n.vs-timeline-diff [data-d=\"-\"]::before { content: \"−\"; }\n.vs-timeline-diff [data-d=\"+\"] { color: var(--vs-timeline-line-ink); background: color-mix(in srgb, var(--vs-timeline-accent) 9%, transparent); }\n.vs-timeline-diff [data-d=\"+\"]::before { content: \"+\"; }\n.vs-timeline[data-vs-timeline-theme=\"light\"] .vs-timeline-diff [data-d=\"-\"] { color: #a2382a; }\n\n/* ---- reveal (only once the script is running) ---- */\n/* entries the head has not reached wait dimmed and soft, then blur-rise in when it arrives */\n.vs-timeline.is-ready .vs-timeline-card { opacity: .5; transform: translate3d(0,16px,0); transition: opacity .45s cubic-bezier(.2,0,0,1), transform .65s cubic-bezier(.16,1,.3,1), box-shadow .5s; }\n.vs-timeline.is-ready :is(.vs-timeline-meta, .vs-timeline-card > *) {\n  opacity: .34; transform: translate3d(0,12px,0); filter: blur(5px);\n  transition: opacity .45s cubic-bezier(.2,0,0,1), transform .6s cubic-bezier(.16,1,.3,1), filter .45s cubic-bezier(.2,0,0,1);\n  transition-delay: 0s;\n}\n.vs-timeline.is-ready .vs-timeline-item.is-in :is(.vs-timeline-card, .vs-timeline-meta, .vs-timeline-card > *) { opacity: 1; transform: none; filter: none; transition-delay: calc(var(--vs-timeline-i, 0) * 70ms); }\n.vs-timeline.is-ready .vs-timeline-item:not(.is-in) :is(.vs-timeline-card, .vs-timeline-meta, .vs-timeline-card > *) { transition-duration: .35s; }\n\n/* ---- single column when narrow ---- */\n@container (max-width: 640px) {\n  .vs-timeline-item, .vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item { grid-template-columns: 36px minmax(0, 1fr); row-gap: 10px; }\n  .vs-timeline-node { grid-column: 1; grid-row: 1; margin-top: 3px; }\n  .vs-timeline-item .vs-timeline-meta, .vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item:nth-child(even) .vs-timeline-meta { grid-column: 2; grid-row: 1; text-align: left; padding-top: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }\n  .vs-timeline-item .vs-timeline-card, .vs-timeline[data-vs-timeline-layout=\"alternate\"] .vs-timeline-item:nth-child(even) .vs-timeline-card { grid-column: 2; grid-row: 2; }\n  .vs-timeline-date { display: inline; font-size: 22px; }\n  .vs-timeline-sub { margin-top: 0; }\n  .vs-timeline-rail, .vs-timeline-fill, .vs-timeline-head, .vs-timeline[data-vs-timeline-layout=\"alternate\"] :is(.vs-timeline-rail, .vs-timeline-fill, .vs-timeline-head) { left: 18px; }\n  .vs-timeline-card { padding: 18px 18px 20px; }\n  .vs-timeline-title { font-size: 21px; }\n  .vs-timeline-body { font-size: 14.5px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .vs-timeline.is-ready :is(.vs-timeline-card, .vs-timeline-meta, .vs-timeline-card > *) { opacity: 1 !important; transform: none !important; filter: none !important; transition: none !important; }\n  .vs-timeline-node i, .vs-timeline-node::before, .vs-timeline-node::after { transition: none !important; }\n}\n</style>\n\n<ol class=\"vs-timeline\" data-vs-timeline-layout=\"alternate\" data-vs-timeline-line=\"solid\" aria-label=\"Tern changelog\">\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-09-18\">18 Sep</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v4.2</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">Offline maps</h3>\n      <p class=\"vs-timeline-body\">Download a region before you leave signal behind. Pins, traces and notes keep working on the trail and sync the moment you are back in range.</p>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-08-27\">27 Aug</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v4.1</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">A command bar for everything</h3>\n      <p class=\"vs-timeline-body\">Jump to a notebook, drop a pin where you stand or start a trace without leaving the page.</p>\n      <div class=\"vs-timeline-keys\"><kbd>⌘</kbd><kbd>K</kbd><span>open anywhere</span></div>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li><li data-tag=\"improved\">Improved</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-07-30\">30 Jul</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v4.0</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">A new sync engine</h3>\n      <p class=\"vs-timeline-body\">Notes now merge field by field instead of last write wins, so two people editing one survey sheet never lose each other's work.</p>\n      <pre class=\"vs-timeline-diff\"><span data-d=\"-\">sync.strategy = \"last-write\"</span><span data-d=\"+\">sync.strategy = \"merge-by-field\"</span><span data-d=\"+\">sync.history  = 90  // days</span></pre>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"improved\">Improved</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-07-02\">02 Jul</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v3.8</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">Voice memos, transcribed</h3>\n      <p class=\"vs-timeline-body\">Hold the record button, talk, let go. Tern files the memo against your current pin and transcribes it on the device.</p>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-06-11\">11 Jun</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v3.7</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">Photo import, four times faster</h3>\n      <p class=\"vs-timeline-body\">A full day of photos comes in while you pour the coffee, and GPS tags from older cameras are finally read correctly.</p>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"improved\">Improved</li><li data-tag=\"fixed\">Fixed</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-05-20\">20 May</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v3.6</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">Shared notebooks</h3>\n      <p class=\"vs-timeline-body\">Invite a field partner with a link. They see your pins as you drop them and can add their own, every edit signed.</p>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li></ul>\n    </article>\n  </li>\n  <li class=\"vs-timeline-item\">\n    <div class=\"vs-timeline-meta\">\n      <time class=\"vs-timeline-date\" datetime=\"2026-04-29\">29 Apr</time>\n      <span class=\"vs-timeline-sub\"><span class=\"vs-timeline-year\">2026</span><span class=\"vs-timeline-ver\">v3.5</span></span>\n    </div>\n    <article class=\"vs-timeline-card\">\n      <h3 class=\"vs-timeline-title\">Red-light night mode</h3>\n      <p class=\"vs-timeline-body\">A deep red theme that keeps your night vision while you log observations after dark. The compass no longer drifts when the screen dims.</p>\n      <ul class=\"vs-timeline-tags\"><li data-tag=\"new\">New</li><li data-tag=\"fixed\">Fixed</li></ul>\n    </article>\n  </li>\n</ol>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const scrollParent = (el) => {\n    for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {\n      const o = getComputedStyle(p).overflowY;\n      if ((o === 'auto' || o === 'scroll' || o === 'overlay') && p.scrollHeight > p.clientHeight + 1) return p;\n    }\n    return window;\n  };\n\n  function init(root) {\n    if (root.vsTimeline) return;\n    const items = [...root.querySelectorAll(':scope > .vs-timeline-item')];\n    const mk = (cls, html = '') => { const e = document.createElement('div'); e.className = cls; e.setAttribute('aria-hidden', 'true'); e.innerHTML = html; root.appendChild(e); return e; };\n    const rail = mk('vs-timeline-rail'), fill = mk('vs-timeline-fill'), head = mk('vs-timeline-head', '<s></s><i></i><b></b>');\n    const glow = head.querySelector('i'), tail = head.querySelector('s');\n    const nodes = items.map((it) => {\n      let n = it.querySelector(':scope > .vs-timeline-node');\n      if (!n) { n = document.createElement('span'); n.className = 'vs-timeline-node'; n.setAttribute('aria-hidden', 'true'); n.innerHTML = '<i></i>'; it.prepend(n); }\n      // stagger index for the blur-rise: card first, then meta, then each child of the card\n      const card = it.querySelector('.vs-timeline-card'), meta = it.querySelector('.vs-timeline-meta');\n      if (card) card.style.setProperty('--vs-timeline-i', 0);\n      if (meta) meta.style.setProperty('--vs-timeline-i', 1);\n      if (card) [...card.children].forEach((c, i) => c.style.setProperty('--vs-timeline-i', i + 1.4));\n      return { el: n, it, y: 0, s: { x: 1, v: 0 }, on: false };\n    });\n    root.classList.add('is-ready');\n\n    let scroller = scrollParent(root);\n    let top0 = 0, top1 = 0; // first and last node centre, relative to the root\n    const measure = () => {\n      const r = root.getBoundingClientRect();\n      nodes.forEach((n) => { const b = n.el.getBoundingClientRect(); n.y = b.top + b.height / 2 - r.top; });\n      top0 = nodes.length ? nodes[0].y : 0; top1 = nodes.length ? nodes[nodes.length - 1].y : 0;\n      rail.style.top = Math.max(0, top0 - 60) + 'px'; rail.style.height = (top1 - top0 + 150) + 'px';\n      fill.style.top = top0 + 'px'; fill.style.height = Math.max(0, top1 - top0) + 'px';\n    };\n    const focusLine = () => {\n      const f = parseFloat(root.dataset.vsTimelineFocus) || .55;\n      if (scroller === window) return innerHeight * f;\n      const r = scroller.getBoundingClientRect(); return r.top + scroller.clientHeight * f;\n    };\n    // head position (px from the first node) on a spring; glow from its velocity\n    const H = { x: 0, v: 0, t: 0 };\n    const K = 300, C = 2 * Math.sqrt(K) * .85;\n    let raf = 0, last = 0, visible = true, cur = -1, primed = false, gl = 0;\n    const target = () => { const r = root.getBoundingClientRect(); return Math.max(0, Math.min(top1 - top0, focusLine() - r.top - top0)); };\n    const kick = () => { if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n    function paint() {\n      const len = top1 - top0, h = Math.max(0, Math.min(len, H.x));\n      fill.style.clipPath = `inset(0 -14px ${(len - h).toFixed(1)}px -14px)`;\n      head.style.transform = `translate3d(0,${(top0 + h).toFixed(1)}px,0)`;\n      head.classList.toggle('is-on', h > 1 && h < len + 1 && !reduce.matches);\n      // glow swells with speed (smoothed), the tail stretches with it\n      const sp = Math.min(1, Math.abs(H.v) / 1400);\n      gl += (sp - gl) * .18;\n      glow.style.transform = `scale(${(0.8 + gl * 1.5).toFixed(3)})`; glow.style.opacity = (0.55 + gl * .45).toFixed(3);\n      tail.style.transform = `scaleY(${Math.min(1, h / 110, 0.35 + gl * .9).toFixed(3)})`;\n      // nodes + reveal\n      let c = -1;\n      nodes.forEach((n, i) => {\n        const y = n.y - top0;\n        const on = h >= y - 1;\n        const inView = h >= y - 120; // entries start rising just before the head arrives\n        n.it.classList.toggle('is-in', inView || reduce.matches);\n        if (on !== n.on) { n.on = on; n.it.classList.toggle('is-on', on); if (primed && !reduce.matches) n.s.v += on ? 9 : -3; }\n        if (on) c = i;\n      });\n      if (c !== cur) {\n        nodes.forEach((n, i) => n.it.classList.toggle('is-current', i === c));\n        if (primed) root.dispatchEvent(new CustomEvent('timelinestep', { bubbles: true, detail: { index: c } }));\n        cur = c;\n      }\n    }\n    function tick(now) {\n      raf = 0;\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now;\n      H.t = reduce.matches ? top1 - top0 : target();\n      let busy = false;\n      if (reduce.matches || !primed) { H.x = H.t; H.v = 0; }\n      else {\n        const n = Math.max(1, Math.ceil(dt * 240)), hh = dt / n;\n        for (let i = 0; i < n; i++) { H.v += (-K * (H.x - H.t) - C * H.v) * hh; H.x += H.v * hh; }\n        if (Math.abs(H.x - H.t) > .2 || Math.abs(H.v) > 2) busy = true; else { H.x = H.t; H.v = 0; }\n      }\n      paint();\n      // node pop springs (scale)\n      for (const n of nodes) {\n        if (Math.abs(n.s.v) < .002 && Math.abs(n.s.x - 1) < .001) continue;\n        const k = 520, c = 2 * Math.sqrt(k) * .32, m = Math.max(1, Math.ceil(dt * 240)), hh = dt / m;\n        for (let i = 0; i < m; i++) { n.s.v += (-k * (n.s.x - 1) - c * n.s.v) * hh; n.s.x += n.s.v * hh; }\n        if (Math.abs(n.s.v) < .002 && Math.abs(n.s.x - 1) < .001) { n.s.x = 1; n.s.v = 0; }\n        n.el.style.transform = n.s.x === 1 ? '' : `scale(${n.s.x.toFixed(4)})`;\n        busy = true;\n      }\n      if (gl > .01) busy = true;\n      if (busy) raf = requestAnimationFrame(tick);\n    }\n    const onScroll = () => kick();\n    const bind = () => { (scroller === window ? window : scroller).addEventListener('scroll', onScroll, { passive: true }); };\n    const unbind = () => { (scroller === window ? window : scroller).removeEventListener('scroll', onScroll); };\n    bind();\n    addEventListener('resize', onScroll, { passive: true });\n    const refresh = () => {\n      const s = scrollParent(root); if (s !== scroller) { unbind(); scroller = s; bind(); }\n      measure(); kick();\n    };\n    new ResizeObserver(refresh).observe(root);\n    new MutationObserver(() => requestAnimationFrame(refresh)).observe(root, { attributes: true, attributeFilter: ['data-vs-timeline-layout', 'data-vs-timeline-theme', 'data-vs-timeline-focus'] });\n    if (document.fonts) document.fonts.ready.then(refresh);\n    new IntersectionObserver((es) => { visible = es[0].isIntersecting; if (visible) kick(); }, { rootMargin: '200px 0px' }).observe(root);\n    reduce.addEventListener?.('change', refresh);\n\n    root.vsTimeline = { refresh };\n    measure(); H.x = H.t = target(); paint(); primed = true;\n  }\n\n  const boot = () => document.querySelectorAll('.vs-timeline').forEach(init);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n  window.vsTimelineInit = boot;\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Data display",
    "tags": [
      "timeline",
      "changelog",
      "scroll",
      "scroll progress",
      "spring",
      "reveal",
      "roadmap",
      "history"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,340;9..144,400&family=Mulish:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#8fe3bf",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/timeline/index.html",
    "thumbnail": "https://vividsites.app/api/component/timeline/thumb.jpg",
    "video": "https://vividsites.app/api/component/timeline/thumb.mp4",
    "page": "https://vividsites.app/c/timeline"
  }
}