{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dot-rail",
  "type": "registry:ui",
  "title": "Dot Rail",
  "description": "A strip of LED dots that shows where you are. A run of lit dots jumps under the tab or link you point at, counts up under an autoplaying tab like a timer, sits at how far you've read, or loops while something loads. The dots it leaves glow for a moment and fade, like an LED sign, so a quick move draws a short comet tail.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "navigation",
    "progress",
    "canvas"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/dot-rail",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/dot-rail.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - One canvas and one rAF loop draw the rail. A frame redraws only the columns whose brightness\n *   step changed, and the loop stops once nothing moves or fades, off screen and on a hidden tab.\n * - A lit dot's halo stays inside its own cell, so redrawing one column never clips a neighbor.\n * - Each end of the run is its own critically damped spring: the end that grows the run uses\n *   `stiffness`, the end that shrinks it `followStiffness`, so the run stretches toward its target.\n * - A dot is on or off by whether its column is inside the run, never half lit by position; only\n *   its fade after the run leaves is gradual, an exponential that's out by `trail`.\n * - A first target and any relayout land without travel, already lit. Under reduced motion every\n *   target does, with no tail, and the loop parks mid-rail and breathes.\n */\nconst TIMING = {\n  /** A dot switching on, in seconds. */\n  rise: 0.03,\n  /** How long a dot keeps glowing after the run leaves it, in ms (the `trail` prop). */\n  trail: 260,\n  /**\n   * Spring stiffness of the end that grows the run (critically damped; the `stiffness` prop), and\n   * of the end that shrinks it (the `followStiffness` prop).\n   */\n  stiffness: 900,\n  followStiffness: 420,\n  /** The run's travel under `follow=\"loop\"`, in dots a second. */\n  loop: 42,\n  /** One breath of the loop under reduced motion, in seconds. */\n  breathe: 2.4,\n  /** The springs' integration step, in seconds. */\n  substep: 0.004,\n  /** The longest step one frame advances the springs and fades by, in seconds (after a stall). */\n  maxFrame: 0.05,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * DOT RAIL STORYBOARD\n *\n *  rest     a band of dots three rows deep, at 30% of the text\n *           color: the dotted strip under a navbar. A run of\n *           them is lit in the accent, like a few lamps on an\n *           LED sign, each with a soft halo inside its own cell\n *  light    a dot the run reaches switches on in 30ms. A dot it\n *           leaves doesn't go dark at once: it fades on an\n *           exponential curve and is out by `trail` (260ms), so\n *           a fast move leaves a short comet tail and a slow one\n *           hardly any. A dot is on or off, never half lit by\n *           position; only the fade is gradual\n *  move     each end of the run chases its new place on a\n *           critically damped spring. The end that grows the run\n *           leads (stiffness 900, in about 0.2s), the end that\n *           shrinks it follows (420, about 0.3s), so the run\n *           stretches toward where it's going and gathers up\n *           behind itself\n *  track    with `trackRef`, the run jumps under whichever link\n *           or button in it is hovered or keyboard-focused, as\n *           wide as that item, and goes back to the one marked\n *           aria-current (or data-active) when the pointer\n *           leaves. Mouse and keyboard only; a tap just changes\n *           which one is current\n *  scroll   `follow=\"scroll\"`: the run sits at the scroll\n *           progress of the page, or of `containerRef`, through\n *           the same springs, so its tail grows with scroll speed\n *  loop     `follow=\"loop\"`, and the default with nothing else\n *           to place it: the run travels left to right at 42 dots\n *           a second and wraps round, a loader or an ambient\n *           strip. A target that takes over keeps its speed\n *  timer    `fill` lights only part of the current item, from\n *           its left edge: drive it from a timer and the dots\n *           under an autoplaying tab switch on one by one. When\n *           the next tab becomes current at 0, the run shrinks\n *           over to it, its tail fading on the last one, and\n *           starts counting again. Hover still shows the whole\n *           item\n *  fill     `value={[0, 1]}` lights the whole rail. Both ends\n *           lead, so it floods out from wherever the run was\n *  sleep    the canvas only redraws the columns whose light\n *           changed, and stops once nothing moves or fades, off\n *           screen and on a hidden tab\n *  reduced  no tail and no travel: the run is drawn straight at\n *           its target. The loop parks mid-rail and breathes,\n *           55% to full over 2.4s\n * ───────────────────────────────────────────────────────── */\n\nconst RAIL = {\n  base: 0.3, // opacity of an unlit dot, as a share of the text color's\n  radius: 0.27, // dot radius, as a share of `pitch`\n  halo: { radius: 0.5, alpha: 0.3 }, // a lit dot's glow: radius as a share of `pitch` (it stays inside its cell), and its opacity at full light\n  floor: 0.03, // brightness under which a fading dot is out\n  levels: 24, // brightness steps a dot can be drawn at\n  rest: { distance: 0.01, speed: 0.05 }, // dots, dots/s: an end nearer and slower than this has landed\n  breathe: { low: 0.55 }, // brightness the loop breathes down to under reduced motion\n  accent: \"#ff5a1f\", // without --dot-rail-accent\n  maxDpr: 2,\n} as const;\n\nconst ITEM = 'a[href], button, [role=\"tab\"], [data-dot-rail-item]';\nconst ACTIVE =\n  '[aria-current]:not([aria-current=\"false\"]), [data-active]:not([data-active=\"false\"]), [aria-selected=\"true\"]';\n\ntype Rgba = [number, number, number, number];\ntype Follow = \"scroll\" | \"loop\";\n\ntype Options = {\n  value: number | [number, number] | undefined;\n  fill: number;\n  follow: Follow | undefined;\n  tracking: boolean;\n  rows: number;\n  pitch: number;\n  length: number;\n  trail: number;\n  stiffness: number;\n  followStiffness: number;\n  reduced: boolean;\n};\n\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\n/** Any CSS color (hex, rgb, oklch, a var() already resolved) as RGBA bytes, read off a 1×1 canvas. */\nfunction makeColorReader() {\n  let ctx: CanvasRenderingContext2D | null = null;\n  return (color: string): Rgba => {\n    if (!ctx) {\n      const c = document.createElement(\"canvas\");\n      c.width = c.height = 1;\n      ctx = c.getContext(\"2d\", { willReadFrequently: true });\n    }\n    if (!ctx) return [0, 0, 0, 1];\n    ctx.clearRect(0, 0, 1, 1);\n    ctx.fillStyle = \"#000\";\n    ctx.fillStyle = color;\n    ctx.fillRect(0, 0, 1, 1);\n    const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;\n    return [r, g, b, a / 255];\n  };\n}\n\ntype Edge = { x: number; v: number; t: number };\n\n/** One end of the run, on a critically damped spring of stiffness `k`. Returns whether it's still moving. */\nfunction stepEdge(edge: Edge, dt: number, k: number) {\n  if (Math.abs(edge.x - edge.t) < RAIL.rest.distance && Math.abs(edge.v) < RAIL.rest.speed) {\n    edge.x = edge.t;\n    edge.v = 0;\n    return false;\n  }\n  const c = 2 * Math.sqrt(k);\n  const n = Math.max(1, Math.ceil(dt / TIMING.substep));\n  const h = dt / n;\n  let { x, v } = edge;\n  for (let i = 0; i < n; i++) {\n    v += (-k * (x - edge.t) - c * v) * h;\n    x += v * h;\n  }\n  edge.x = x;\n  edge.v = v;\n  return true;\n}\n\nfunction createRail() {\n  let root: HTMLElement | null = null;\n  let canvas: HTMLCanvasElement | null = null;\n  let ctx: CanvasRenderingContext2D | null = null;\n  const readColor = makeColorReader();\n\n  let options: Options = {\n    value: undefined,\n    fill: 1,\n    follow: undefined,\n    tracking: false,\n    rows: 3,\n    pitch: 6,\n    length: 6,\n    trail: TIMING.trail,\n    stiffness: TIMING.stiffness,\n    followStiffness: TIMING.followStiffness,\n    reduced: false,\n  };\n\n  // Geometry, in CSS px. Column c's dots are centered at ox + c * pitch; the\n  // run's ends are in column-edge units, so column c is lit when\n  // start <= c + 0.5 < end.\n  let dpr = 1;\n  let width = 0;\n  let height = 0;\n  let cols = 0;\n  let ox = 0;\n  let glow = new Float32Array(0); // 0…1 per column\n  let drawn = new Int16Array(0); // brightness step last drawn per column, -1 for none\n  let palette: string[] = [];\n  let halos: string[] = []; // the glow around a dot, per brightness step\n\n  const start: Edge = { x: 0, v: 0, t: 0 };\n  const end: Edge = { x: 0, v: 0, t: 0 };\n  let placed = false; // the run has had a first target; until then it lands without travel\n  let configured = false; // options have arrived from React; nothing is placed before that\n  let ownsValueNow = false; // aria-valuenow was set here (scroll), not rendered by React\n  let looping = false;\n  let breathing = false;\n\n  // What places the run.\n  let scroller: HTMLElement | Window | null = null;\n  let track: HTMLElement | null = null;\n  let hovered: Element | null = null;\n  let focused: Element | null = null;\n\n  let raf = 0;\n  let last = 0;\n  let visible = false;\n  const cleanups: (() => void)[] = [];\n  let unbindScroll: (() => void) | null = null;\n  let unbindTrack: (() => void) | null = null;\n\n  function readColors() {\n    if (!root) return;\n    const css = getComputedStyle(root);\n    const ink = readColor(css.color);\n    const accent = readColor(css.getPropertyValue(\"--dot-rail-accent\").trim() || RAIL.accent);\n    const top = RAIL.levels - 1;\n    palette = Array.from({ length: RAIL.levels }, (_, i) => {\n      const g = i / top;\n      const mix = (a: number, b: number) => Math.round(a + (b - a) * g);\n      const alpha = ink[3] * RAIL.base * (1 - g) + accent[3] * g;\n      return `rgba(${mix(ink[0], accent[0])}, ${mix(ink[1], accent[1])}, ${mix(ink[2], accent[2])}, ${alpha.toFixed(3)})`;\n    });\n    halos = Array.from({ length: RAIL.levels }, (_, i) => {\n      const g = i / top;\n      return `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${(accent[3] * RAIL.halo.alpha * g * g).toFixed(3)})`;\n    });\n    drawn.fill(-1);\n  }\n\n  function layout() {\n    if (!canvas || !ctx) return;\n    dpr = Math.min(window.devicePixelRatio || 1, RAIL.maxDpr);\n    width = canvas.clientWidth;\n    height = canvas.clientHeight;\n    canvas.width = Math.max(1, Math.round(width * dpr));\n    canvas.height = Math.max(1, Math.round(height * dpr));\n    const pitch = options.pitch;\n    cols = Math.max(1, Math.floor(width / pitch));\n    ox = (width - cols * pitch) / 2 + pitch / 2;\n    glow = new Float32Array(cols);\n    drawn = new Int16Array(cols).fill(-1);\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n    // A new width moves every target: land on them without travel, lit already.\n    placed = false;\n    resolve();\n    wake();\n  }\n\n  const lit = (c: number) => c + 0.5 >= start.x && c + 0.5 < end.x;\n\n  /** Left and right edge of an element, in column-edge units. */\n  function span(el: Element): [number, number] {\n    if (!canvas) return [0, 0];\n    const box = canvas.getBoundingClientRect();\n    const r = el.getBoundingClientRect();\n    const u = (x: number) => (x - box.left - ox) / options.pitch + 0.5;\n    let a = u(r.left);\n    let b = u(r.right);\n    if (b - a < 1) {\n      const mid = (a + b) / 2;\n      a = mid - 0.5;\n      b = mid + 0.5;\n    }\n    return [a, b];\n  }\n\n  function progress() {\n    if (!scroller) return 0;\n    if (scroller instanceof Window) {\n      const max = document.documentElement.scrollHeight - window.innerHeight;\n      return max > 0 ? clamp01(window.scrollY / max) : 0;\n    }\n    const max = scroller.scrollHeight - scroller.clientHeight;\n    return max > 0 ? clamp01(scroller.scrollTop / max) : 0;\n  }\n\n  /** A run of `length` dots at p along the rail: 0 sits at the left end, 1 at the right. */\n  function at(p: number): [number, number] {\n    const len = Math.min(options.length, cols);\n    const a = clamp01(p) * (cols - len);\n    return [a, a + len];\n  }\n\n  function aim(a: number, b: number) {\n    start.t = a;\n    end.t = b;\n    if (!placed || options.reduced) {\n      // Land without travel, already lit, so the first frame is the rest state.\n      const landing = !placed;\n      start.x = a;\n      end.x = b;\n      start.v = end.v = 0;\n      placed = true;\n      if (landing) for (let c = 0; c < cols; c++) glow[c] = lit(c) ? 1 : 0;\n    }\n  }\n\n  /** Work out where the run belongs now, from the highest-priority source that has an answer. */\n  function resolve() {\n    if (!root || !cols || !configured) return;\n    looping = breathing = false;\n    // The sources bind in effects after the options land; don't place the run\n    // for a source that isn't there yet, or it would travel in from nowhere.\n    if (options.tracking && !track) return;\n    if (options.follow === \"scroll\" && !scroller) return;\n\n    const active = track?.querySelector(ACTIVE);\n    const current = active ? (active.closest(ITEM) ?? active.querySelector(ITEM) ?? active) : null;\n    const item = hovered ?? focused ?? current;\n    if (item && track?.contains(item)) {\n      const [a, b] = span(item);\n      // A timer counts only under the current item; one pointed at shows whole.\n      const share = item === current && item !== hovered && item !== focused ? clamp01(options.fill) : 1;\n      aim(a, a + (b - a) * share);\n    } else if (options.follow === \"scroll\") {\n      const p = progress();\n      root.setAttribute(\"aria-valuenow\", String(Math.round(p * 100)));\n      ownsValueNow = true;\n      const [a, b] = at(p);\n      aim(a, b);\n    } else if (options.follow === \"loop\" || (options.follow === undefined && options.value === undefined && !options.tracking)) {\n      if (options.reduced) {\n        breathing = true;\n        aim(...at(0.5));\n      } else {\n        // Carry on from wherever the run is, or come in from the left on a first run.\n        looping = true;\n        if (!placed) aim(-options.length, 0);\n        end.t = start.t + Math.min(options.length, cols);\n      }\n    } else if (typeof options.value === \"number\") {\n      aim(...at(options.value));\n    } else if (Array.isArray(options.value)) {\n      const lo = clamp01(Math.min(options.value[0], options.value[1]));\n      const hi = clamp01(Math.max(options.value[0], options.value[1]));\n      aim(lo * cols, hi * cols);\n    } else if (placed) {\n      // Nothing current: the run closes up where it stands.\n      const mid = (start.x + end.x) / 2;\n      aim(mid, mid);\n    } else {\n      // Dark from the start, without counting as placed, so the first real\n      // target lands where it is instead of growing out of the middle.\n      start.x = end.x = start.t = end.t = cols / 2;\n    }\n    if (options.follow !== \"scroll\" && ownsValueNow) {\n      root.removeAttribute(\"aria-valuenow\");\n      ownsValueNow = false;\n    }\n    wake();\n  }\n\n  function drawColumn(c: number, level: number) {\n    if (!ctx) return;\n    const p = options.pitch * dpr;\n    const x = (ox + c * options.pitch) * dpr;\n    const r = RAIL.radius * p;\n    ctx.clearRect(x - p / 2, 0, p, height * dpr);\n    if (level > 0) {\n      // The glow fills the dot's own cell and no more, so redrawing one column never clips a neighbor's.\n      const halo = RAIL.halo.radius * p;\n      ctx.fillStyle = halos[level];\n      ctx.beginPath();\n      for (let row = 0; row < options.rows; row++) {\n        const y = (row + 0.5) * p;\n        ctx.moveTo(x + halo, y);\n        ctx.arc(x, y, halo, 0, Math.PI * 2);\n      }\n      ctx.fill();\n    }\n    ctx.fillStyle = palette[level];\n    ctx.beginPath();\n    for (let row = 0; row < options.rows; row++) {\n      const y = (row + 0.5) * p;\n      ctx.moveTo(x + r, y);\n      ctx.arc(x, y, r, 0, Math.PI * 2);\n    }\n    ctx.fill();\n  }\n\n  function frame(time: number) {\n    raf = 0;\n    if (!ctx || !palette.length) return;\n    const dt = last ? Math.min(TIMING.maxFrame, (time - last) / 1000) : 1 / 60;\n    last = time;\n    let busy = looping || breathing;\n\n    if (looping) {\n      const len = Math.min(options.length, cols);\n      start.t += TIMING.loop * dt;\n      end.t = start.t + len;\n      // Wrap once the run is fully off the right end, moving targets and\n      // springs together so nothing sweeps back across.\n      if (start.x > cols) {\n        const shift = cols + len;\n        start.x -= shift;\n        end.x -= shift;\n        start.t -= shift;\n        end.t -= shift;\n      }\n    }\n    const spring = (grows: boolean) => (grows ? options.stiffness : options.followStiffness);\n    if (stepEdge(start, dt, spring(start.t < start.x))) busy = true;\n    if (stepEdge(end, dt, spring(end.t > end.x))) busy = true;\n\n    const top = RAIL.levels - 1;\n    const fade = options.reduced || options.trail <= 0 ? 0 : Math.exp(-dt / (options.trail / 1000 / 3));\n    const peak = breathing\n      ? RAIL.breathe.low + (1 - RAIL.breathe.low) * (0.5 + 0.5 * Math.cos((2 * Math.PI * time) / 1000 / TIMING.breathe))\n      : 1;\n    for (let c = 0; c < cols; c++) {\n      let g = glow[c];\n      if (lit(c)) {\n        if (g < 1) {\n          g = options.reduced ? 1 : Math.min(1, g + dt / TIMING.rise);\n          busy = true;\n        }\n      } else if (g > 0) {\n        g *= fade;\n        if (g < RAIL.floor) g = 0;\n        busy = true;\n      }\n      glow[c] = g;\n      const level = Math.round((lit(c) ? g * peak : g) * top);\n      if (level !== drawn[c]) {\n        drawColumn(c, level);\n        drawn[c] = level;\n      }\n    }\n\n    if (busy && visible && !document.hidden) raf = requestAnimationFrame(frame);\n    else last = 0;\n  }\n\n  function wake() {\n    if (raf || !visible || !ctx || document.hidden) return;\n    raf = requestAnimationFrame(frame);\n  }\n\n  return {\n    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement }) {\n      root = el.root;\n      canvas = el.canvas;\n      ctx = canvas.getContext(\"2d\");\n      readColors();\n      layout();\n\n      const resize = new ResizeObserver(() => {\n        if (!canvas) return;\n        if (canvas.clientWidth === width && canvas.clientHeight === height) return;\n        layout();\n      });\n      resize.observe(canvas);\n      const io = new IntersectionObserver(([entry]) => {\n        visible = entry.isIntersecting;\n        if (visible) wake();\n      });\n      io.observe(canvas);\n\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      const recolor = () => {\n        readColors();\n        wake();\n      };\n      scheme.addEventListener(\"change\", recolor);\n      const themeAttrs = new MutationObserver(recolor);\n      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-theme\"] });\n\n      const pageVisibility = () => {\n        if (!document.hidden) wake();\n      };\n      document.addEventListener(\"visibilitychange\", pageVisibility);\n\n      cleanups.push(\n        () => resize.disconnect(),\n        () => io.disconnect(),\n        () => scheme.removeEventListener(\"change\", recolor),\n        () => themeAttrs.disconnect(),\n        () => document.removeEventListener(\"visibilitychange\", pageVisibility),\n      );\n      return () => {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        last = 0;\n        visible = false;\n        cleanups.splice(0).forEach((fn) => fn());\n        root = canvas = null;\n        ctx = null;\n      };\n    },\n\n    setOptions(next: Options) {\n      const prev = options;\n      options = next;\n      if (!ctx) return;\n      if (!configured) {\n        configured = true;\n        layout();\n        return;\n      }\n      if (next.rows !== prev.rows || next.pitch !== prev.pitch) {\n        layout();\n        return;\n      }\n      if (next.reduced && !prev.reduced) {\n        // Settle at once: no travel, no tail.\n        start.x = start.t;\n        end.x = end.t;\n        start.v = end.v = 0;\n      }\n      resolve();\n    },\n\n    bindScroll(target: HTMLElement | Window | null) {\n      unbindScroll?.();\n      unbindScroll = null;\n      scroller = target;\n      if (target) {\n        const onScroll = () => resolve();\n        target.addEventListener(\"scroll\", onScroll, { passive: true });\n        window.addEventListener(\"resize\", onScroll);\n        unbindScroll = () => {\n          target.removeEventListener(\"scroll\", onScroll);\n          window.removeEventListener(\"resize\", onScroll);\n        };\n      }\n      resolve();\n    },\n\n    bindTrack(el: HTMLElement | null) {\n      unbindTrack?.();\n      unbindTrack = null;\n      track = el;\n      hovered = focused = null;\n      if (el) {\n        const itemOf = (target: EventTarget | null) => {\n          const item = target instanceof Element ? target.closest(ITEM) : null;\n          return item && el.contains(item) ? item : null;\n        };\n        const over = (e: PointerEvent) => {\n          if (e.pointerType === \"touch\") return;\n          const item = itemOf(e.target);\n          if (item && item !== hovered) {\n            hovered = item;\n            resolve();\n          }\n        };\n        const leave = () => {\n          if (!hovered) return;\n          hovered = null;\n          resolve();\n        };\n        const focusIn = (e: FocusEvent) => {\n          const item = itemOf(e.target);\n          // Keyboard focus only: a click focuses a link too, and the run should\n          // go back to the current item, not stay where the mouse clicked.\n          focused = item && item.matches(\":focus-visible\") ? item : null;\n          resolve();\n        };\n        const focusOut = (e: FocusEvent) => {\n          if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;\n          focused = null;\n          resolve();\n        };\n        el.addEventListener(\"pointerover\", over);\n        el.addEventListener(\"pointerleave\", leave);\n        el.addEventListener(\"focusin\", focusIn);\n        el.addEventListener(\"focusout\", focusOut);\n\n        // The current item can change, and items move when the nav reflows or a\n        // web font lands.\n        const sizes = new ResizeObserver(() => resolve());\n        const observeItems = () => {\n          sizes.disconnect();\n          sizes.observe(el);\n          el.querySelectorAll(ITEM).forEach((item) => sizes.observe(item));\n        };\n        observeItems();\n        const changes = new MutationObserver((records) => {\n          if (records.some((r) => r.type === \"childList\")) observeItems();\n          resolve();\n        });\n        changes.observe(el, {\n          subtree: true,\n          childList: true,\n          attributes: true,\n          attributeFilter: [\"aria-current\", \"data-active\", \"aria-selected\"],\n        });\n\n        unbindTrack = () => {\n          el.removeEventListener(\"pointerover\", over);\n          el.removeEventListener(\"pointerleave\", leave);\n          el.removeEventListener(\"focusin\", focusIn);\n          el.removeEventListener(\"focusout\", focusOut);\n          sizes.disconnect();\n          changes.disconnect();\n        };\n      }\n      resolve();\n    },\n\n    disconnectSources() {\n      unbindScroll?.();\n      unbindTrack?.();\n      unbindScroll = unbindTrack = null;\n      scroller = null;\n      track = hovered = focused = null;\n    },\n  };\n}\n\ntype Rail = ReturnType<typeof createRail>;\n\nexport interface DotRailProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Where the run sits: a number from 0 (the left end) to 1 (the right end), or a `[from, to]` range to light, such as `[0, 1]` for the whole rail. `follow` and a hovered `trackRef` item take precedence. */\n  value?: number | [number, number];\n  /** Let the run place itself: `\"scroll\"` sits it at the scroll progress of the page (or `containerRef`), `\"loop\"` sends it round the rail on its own, like a loader. With no `follow`, `value` or `trackRef`, it loops. */\n  follow?: Follow;\n  /** The scrolling element whose progress `follow=\"scroll\"` reads. Defaults to the page. */\n  containerRef?: React.RefObject<HTMLElement | null>;\n  /** A nav, tab list or menu to follow: the run jumps under whichever of its links or buttons is hovered or focused, as wide as it is, and goes back to the one marked `aria-current` (or `data-active`, or `aria-selected`) when the pointer leaves. */\n  trackRef?: React.RefObject<HTMLElement | null>;\n  /** With `trackRef`, how much of the current item is lit, from its left edge, from 0 to 1. Drive it from a timer and the dots under an autoplaying tab count up one by one. */\n  fill?: number;\n  /** Rows of dots in the band. */\n  rows?: number;\n  /** Distance between dot centers, in px. The rail is `rows × pitch` tall. */\n  pitch?: number;\n  /** How many dots wide the run is when it's a position (a number `value`, scroll or loop). */\n  length?: number;\n  /** How long, in ms, a dot keeps glowing after the run leaves it. `0` turns the tail off. */\n  trail?: number;\n  /** Spring stiffness of the end that leads the run when it moves. Higher is snappier. */\n  stiffness?: number;\n  /** Spring stiffness of the end that catches up behind it. Lower than `stiffness` and the run stretches toward where it's going. */\n  followStiffness?: number;\n}\n\nexport function DotRail({\n  value,\n  follow,\n  containerRef,\n  trackRef,\n  fill = 1,\n  rows = 3,\n  pitch = 6,\n  length = 6,\n  trail = TIMING.trail,\n  stiffness = TIMING.stiffness,\n  followStiffness = TIMING.followStiffness,\n  className,\n  style,\n  ...props\n}: DotRailProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const [rail] = React.useState<Rail>(createRail);\n  const reduced = useReducedMotion() ?? false;\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const disconnect = rail.connect({ root, canvas });\n    return () => {\n      rail.disconnectSources();\n      disconnect();\n    };\n  }, [rail]);\n\n  // A range arrives as a new array each render; compare its ends instead.\n  const from = Array.isArray(value) ? value[0] : value;\n  const to = Array.isArray(value) ? value[1] : undefined;\n  React.useEffect(() => {\n    rail.setOptions({\n      value: from === undefined ? undefined : to === undefined ? from : [from, to],\n      fill,\n      follow,\n      tracking: trackRef !== undefined,\n      rows: Math.max(1, Math.round(rows)),\n      pitch: Math.max(2, pitch),\n      length: Math.max(1, Math.round(length)),\n      trail: Math.max(0, trail),\n      stiffness: Math.max(1, stiffness),\n      followStiffness: Math.max(1, followStiffness),\n      reduced,\n    });\n  }, [rail, from, to, fill, follow, trackRef, rows, pitch, length, trail, stiffness, followStiffness, reduced]);\n\n  // Refs are attached by the time effects run, including a parent's.\n  React.useEffect(() => {\n    rail.bindScroll(follow === \"scroll\" ? (containerRef?.current ?? window) : null);\n  }, [rail, follow, containerRef]);\n  React.useEffect(() => {\n    rail.bindTrack(trackRef?.current ?? null);\n  }, [rail, trackRef]);\n\n  const position = follow === \"scroll\" || (follow === undefined && typeof value === \"number\");\n  return (\n    <div\n      ref={rootRef}\n      role={position ? \"progressbar\" : undefined}\n      aria-label={position ? (follow === \"scroll\" ? \"Scroll progress\" : \"Progress\") : undefined}\n      aria-valuemin={position ? 0 : undefined}\n      aria-valuemax={position ? 100 : undefined}\n      aria-valuenow={follow === undefined && typeof value === \"number\" ? Math.round(clamp01(value) * 100) : undefined}\n      className={cn(\"relative w-full\", className)}\n      style={{ height: Math.max(1, Math.round(rows)) * Math.max(2, pitch), ...style }}\n      {...props}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"pointer-events-none absolute inset-0 size-full\" />\n    </div>\n  );\n}\n"
    }
  ]
}