{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marquee",
  "type": "registry:ui",
  "title": "Marquee",
  "description": "An endless horizontal band with softly fading edges that coasts to a stop when you point at it or tab into it and picks up speed when you leave, with a small Pause button in a header row above it that never covers the band (WCAG 2.2.2). The content is copied just often enough to leave no gap and one copy spans whole pixels, so the loop has no visible seam; the copies are hidden from screen readers and the Tab key, and keyboard focus glides the focused item to the middle.",
  "author": "ARLing s. r. o. (https://arling.sk)",
  "registryDependencies": [
    "https://arling.sk/motion/r/core.json"
  ],
  "files": [
    {
      "path": "registry/arling/lib/arling-motion-marquee.js",
      "content": "'use strict';\n// ARLing Motion: marquee. MIT licence, https://arling.sk/motion/ . Installed from the ARLing\n// Motion registry (source: components/marquee/marquee.js). Plain JavaScript; the .d.ts next to it types the exports.\n/*\n * ARLing Motion: Marquee.\n * An endless horizontal band, like the Marquee of Magic UI or the logo bands of Linear and\n * Vercel. The position of the band is a pure function of time: the distance so far is the speed\n * times the integral of a rate (1 is full speed, 0 is standing), and that integral is written in\n * closed form for every spring (springArea below), so seek(t) paints any moment exactly.\n * Pointing at the band or moving focus into it adds one spring that takes the rate to 0, so the\n * band coasts to a stop instead of jumping; leaving adds a spring back to 1, so it picks up speed\n * again. Keyboard focus also glides the focused item into the middle of the band on a spring, so\n * what has focus can be read. Both edges fade out (mask-image), so items come and go softly.\n * A small button in a header row above the band pauses it for good (WCAG 2.2.2 Pause, Stop,\n * Hide). It never covers the band, it says Pause or Play in words, and its icon turns from two\n * bars into a triangle on a spring.\n *\n * The content is copied exactly as often as the width needs, so the band never shows a gap:\n * ceil(width / content) copies after the original, and one before it, so that an item near the\n * start can glide to the middle while it has focus and the band still has no gap on the left.\n * The copies are aria-hidden and inert: screen readers and the Tab key meet every item once,\n * in the original, and the original is always the one shown while it has focus.\n * One group is made to span a whole number of device pixels (up to MAX_PAD px of extra space\n * after its last item, through --am-marquee-pad), so every copy sits on the pixel grid the same\n * way and the step from one copy to the next is invisible at any width.\n * With prefers-reduced-motion the band stands at its start, the copies are hidden, the button\n * is hidden and the band scrolls sideways like a normal list.\n *\n * Markup:\n *   <div class=\"am-marquee\" role=\"group\" aria-labelledby=\"tools-title\" data-speed=\"40\">\n *     <div class=\"am-marquee-header\">\n *       <span class=\"am-marquee-title\" id=\"tools-title\">Our tools</span>\n *     </div>\n *     <div class=\"am-marquee-viewport\">\n *       <div class=\"am-marquee-track\">\n *         <ul class=\"am-marquee-group\">\n *           <li class=\"am-marquee-item\"><a href=\"/tool-a/\">Tool A</a></li>\n *           <li class=\"am-marquee-item\"><a href=\"/tool-b/\">Tool B</a></li>\n *         </ul>\n *       </div>\n *     </div>\n *   </div>\n * The component adds the parts that are missing: the header row, the viewport, the track, the\n * copies and the pause button .am-marquee-toggle at the end of the header row. A button of your\n * own may sit anywhere (o.button, or a .am-marquee-toggle deeper inside the root); an empty one\n * gets the icon and the words. Options may also come from data-speed (px per second),\n * data-direction (\"left\" or \"right\") and data-pause-on-hover=\"false\".\n * Horizontal, in a left to right layout. Calls are expected in time order.\n * MIT licence.\n */\nimport { track, driver, steps, attr, spring, springDisp, PRESETS } from './arling-motion';\n\n/** Default speed, px per second. */\nexport const SPEED = 40;\n/**\n * The rate falls to 0 and climbs back to 1 on one and the same critically damped spring. Its step\n * only ever rises, so any run of stops and starts adds up to a rate between 0 and 1: however\n * often the pointer comes and goes, the band never runs backwards and never faster than set.\n */\nexport const STOP = spring(0.6, 1);\nexport const START = STOP;\n/** Keyboard focus glides the focused item to the middle on this spring: calm, no visible overshoot. */\nexport const GLIDE = PRESETS.gentle;\n/** The pause icon turns from two bars into a triangle, and back, on this spring. */\nexport const ICON = PRESETS.snappy;\nexport const DIRECTIONS = ['left', 'right'];\n/** aria-label of the button while the band runs (pause) and while it is paused (play). Each begins with the word shown on the button (WCAG 2.5.3). */\nexport const LABELS = { pause: 'Pause scrolling', play: 'Play scrolling' };\n/** The words on the button. Both sit in one cell, so the button keeps its width when they swap. */\nexport const TEXTS = { pause: 'Pause', play: 'Play' };\n/** At most this many px of extra space after the last item, to put one group on whole device pixels. */\nexport const MAX_PAD = 4;\n/** A frame time further than this (seconds) from the time now is stale (nothing was drawn for a while): the time now is used. */\nconst FRESH = 0.05;\n\nconst TWO_PI = 2 * Math.PI;\nconst px = (v) => `${Math.round(v * 100) / 100}px`;\nconst clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);\nconst FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';\n\n/** Velocity of springDisp(sp, tau, 1, 0): a unit displacement let go at rest, closed form. */\nfunction dispVel(sp, tau) {\n  const w = TWO_PI / sp.response;\n  const z = sp.damping;\n  if (Math.abs(z - 1) < 1e-6) return -w * w * tau * Math.exp(-w * tau);\n  if (z < 1) {\n    const wd = w * Math.sqrt(1 - z * z);\n    return -Math.exp(-z * w * tau) * ((w * w) / wd) * Math.sin(wd * tau);\n  }\n  const s = Math.sqrt(z * z - 1);\n  const r1 = -w * (z - s);\n  const r2 = -w * (z + s);\n  const A = -r2 / (r1 - r2);\n  return A * r1 * Math.exp(r1 * tau) + (1 - A) * r2 * Math.exp(r2 * tau);\n}\n\n/** Seconds a unit step of this spring ends up behind an instant jump: 2 * damping / omega. */\nexport const lagOf = (sp) => (sp.damping * sp.response) / Math.PI;\n\n/**\n * The integral of springStep(sp, s) for s from 0 to tau, in closed form: how far a speed that\n * steps from 0 to 1 along the spring has carried something after tau seconds. From the spring\n * equation x'' + 2zw x' + w^2 x = 0 with x(0) = 1 and x'(0) = 0, the integral of the\n * displacement x is (2zw (1 - x) - x') / w^2, and the step is 1 - x. Once the spring is home\n * (springDisp returns exactly 0) this is tau - lagOf(sp).\n */\nexport function springArea(sp, tau) {\n  if (tau <= 0) return 0;\n  const w = TWO_PI / sp.response;\n  const x = springDisp(sp, tau, 1, 0);\n  const v = x === 0 ? 0 : dispVel(sp, tau);\n  return tau - (2 * sp.damping * w * (1 - x) - v) / (w * w);\n}\n\n/**\n * How many groups a viewport W px wide needs with content P px wide, the original included:\n * one copy before it and ceil(W / P) after it. The band shows the track from P + phase. The\n * phase is in [0, P) while it runs and, while an item has keyboard focus, in a window of one\n * group around where that item rests, which stays within [-P, 1.5 P - W / 2). In both cases\n * the groups reach past the right edge of the viewport, so there is never a gap.\n */\nexport function copiesFor(W, P) {\n  return P > 0 && W > 0 ? Math.ceil(W / P) + 2 : 3;\n}\n\n/**\n * The smallest step, in layout px, by which the width of one group can grow so that it spans a\n * whole number of device pixels at `scale` device pixels per layout px and stays a multiple of\n * 1/64 px (the layout unit of browsers): 1 at scale 1, 0.5 at 2, 1 at 3, 2 at 1.5, 4 at 1.25.\n * 1 when no such step up to MAX_PAD exists (for example at a zoom of 110 %).\n */\nexport function periodUnit(scale) {\n  if (!(scale > 0)) return 1;\n  for (let m = 1; m / scale <= MAX_PAD + 1e-9; m++) {\n    const u = m / scale;\n    const q = u * 64;\n    if (Math.abs(q - Math.round(q)) < 1e-3) return Math.round(q) / 64;\n  }\n  return 1;\n}\n\nconst stoppedBy = (f) => f.paused || f.hovered || f.focused;\n\n/**\n * createMarquee({ root, speed, direction, pauseOnHover, paused, labels, texts, button,\n *   onPauseChange, clock, reduced })\n * speed: px per second (default 40); direction: 'left' (default) or 'right'; paused: start\n * paused; labels: { pause, play }, the aria-labels of the button; texts: { pause, play }, the\n * words on it; button: a pause button of your own, anywhere on the page. onPauseChange(paused)\n * runs when the button (or pause, play, toggle without { t }) changes the paused state.\n * hover(on, { t }), focusItem(i, { t, glide }), blur({ t }), pause, play and toggle schedule\n * input for demos; loop(duration) makes a scheduled demo end where it started.\n * Returns { root, header, viewport, track, button, groups, group, items, driver, keep, hover,\n * focusItem, blur, pause, play, toggle, paused, loop, measure, refresh, offset, speed, period,\n * pad, seek, settled, destroy }.\n */\nexport function createMarquee(o) {\n  const root = o.root;\n  const doc = root.ownerDocument || document;\n  const view = doc.defaultView || globalThis;\n  const data = (k) => (root.hasAttribute(`data-${k}`) ? root.getAttribute(`data-${k}`) : undefined);\n  const num = (v, fallback) => (v === undefined || v === null || v === '' || !Number.isFinite(Number(v)) ? fallback : Number(v));\n  const make = (cls, tag = 'div') => { const el = doc.createElement(tag); el.className = cls; return el; };\n\n  // ---------------------------------------------------------------- parts\n  root.classList.add('am-marquee');\n  for (const old of root.querySelectorAll('[data-am-copy]')) old.remove();\n  const group = root.querySelector('.am-marquee-group');\n  if (!group) throw new Error('createMarquee: no .am-marquee-group inside the root');\n  let viewport = root.querySelector('.am-marquee-viewport');\n  if (!viewport) {\n    viewport = make('am-marquee-viewport');\n    root.insertBefore(viewport, root.firstElementChild);\n  }\n  let trackEl = viewport.querySelector('.am-marquee-track');\n  if (!trackEl) {\n    trackEl = make('am-marquee-track');\n    viewport.appendChild(trackEl);\n  }\n  if (group.parentNode !== trackEl) trackEl.insertBefore(group, trackEl.firstElementChild);\n\n  const labels = { ...LABELS, ...(o.labels || {}) };\n  const texts = { ...TEXTS, ...(o.texts || {}) };\n  let header = root.querySelector('.am-marquee-header');\n  let button = o.button || root.querySelector('.am-marquee-toggle');\n  if (!button) button = make('am-marquee-toggle', 'button');\n  // The button belongs to the header row above the band, never next to it or over it. A button\n  // placed right in the root (the markup of v1) moves there too.\n  if (!button.parentNode || button.parentNode === root) {\n    if (!header) {\n      header = make('am-marquee-header');\n      viewport.parentNode.insertBefore(header, viewport);\n    }\n    header.appendChild(button);\n  }\n  if (button.tagName === 'BUTTON' && !button.hasAttribute('type')) attr(button, 'type', 'button');\n  // an empty button gets the icon and both words (the one not in force is hidden by CSS)\n  if (!button.children.length && !(button.textContent || '').trim()) {\n    const icon = make('am-marquee-toggle-icon', 'span');\n    attr(icon, 'aria-hidden', 'true');\n    const words = make('am-marquee-toggle-text', 'span');\n    attr(words, 'aria-hidden', 'true');\n    const wPause = make('am-marquee-toggle-pause', 'span');\n    wPause.textContent = texts.pause;\n    const wPlay = make('am-marquee-toggle-play', 'span');\n    wPlay.textContent = texts.play;\n    words.append(wPause, wPlay);\n    button.append(icon, words);\n  }\n\n  const speed0 = Math.max(0, num(o.speed ?? data('speed'), SPEED));\n  const s = (o.direction ?? data('direction')) === 'right' ? -1 : 1;\n  const pauseOnHover = o.pauseOnHover ?? data('pause-on-hover') !== 'false';\n\n  let items = [];\n  let lead = null; // the copy before the original\n  const after = []; // the copies after it\n  const copies = () => (lead ? [lead, ...after] : after.slice());\n  const readItems = () => { items = [...group.children].filter((c) => c.tagName); };\n  readItems();\n\n  /** A copy of the content: hidden from screen readers and the Tab key, without ids. */\n  function copy() {\n    const c = group.cloneNode(true);\n    c.removeAttribute('id');\n    for (const el of c.querySelectorAll('[id]')) el.removeAttribute('id');\n    for (const el of c.querySelectorAll('[data-focused]')) el.removeAttribute('data-focused');\n    attr(c, 'data-am-copy', true);\n    attr(c, 'aria-hidden', 'true');\n    attr(c, 'inert', true);\n    return c;\n  }\n  /** n groups in all (copiesFor): one copy before the original, n - 2 after it. */\n  function setCopies(n) {\n    if (!lead) {\n      lead = copy();\n      trackEl.insertBefore(lead, group);\n    }\n    while (after.length < n - 2) {\n      const c = copy();\n      trackEl.appendChild(c);\n      after.push(c);\n    }\n    while (after.length > Math.max(0, n - 2)) after.pop().remove();\n  }\n  function clearCopies() {\n    if (lead) lead.remove();\n    lead = null;\n    while (after.length) after.pop().remove();\n  }\n\n  // ---------------------------------------------------------------- geometry, per layout change\n  /** Width of the box in layout px, exact to a few decimals where the browser can say it (offsetWidth is rounded). */\n  function layoutWidth(el) {\n    const cs = typeof view.getComputedStyle === 'function' ? view.getComputedStyle(el) : null;\n    const w = cs ? parseFloat(cs.width) : NaN;\n    if (w > 0) {\n      if (cs.boxSizing === 'border-box') return w;\n      const n = (v) => parseFloat(v) || 0;\n      return w + n(cs.paddingLeft) + n(cs.paddingRight) + n(cs.borderLeftWidth) + n(cs.borderRightWidth);\n    }\n    return el.offsetWidth || 0;\n  }\n\n  function measureGeo() {\n    const vr = viewport.getBoundingClientRect();\n    const gr = group.getBoundingClientRect();\n    // k: screen px per layout px, for a band inside a scaled parent. A k within 1 % of 1 counts\n    // as 1, so an unscaled band uses the exact subpixel width of its box: the loop needs the\n    // exact width of a group, or the band would jump a little at every round.\n    const lw = layoutWidth(group);\n    const raw = lw > 0 && gr.width > 0 ? gr.width / lw : 1;\n    const k = Math.abs(raw - 1) < 0.01 ? 1 : raw;\n    return {\n      k,\n      W: vr.width / k,\n      P: gr.width / k,\n      items: items.map((el) => {\n        const r = el.getBoundingClientRect();\n        return { x: (r.left - gr.left) / k, w: r.width / k };\n      }),\n    };\n  }\n\n  // The extra space after the last item of every group, px (the CSS adds it to the gap).\n  let pad = parseFloat(root.style.getPropertyValue('--am-marquee-pad')) || 0;\n  const dpr = () => (view.devicePixelRatio > 0 ? view.devicePixelRatio : 1);\n  function setPad(v) {\n    pad = v;\n    if (v > 0) root.style.setProperty('--am-marquee-pad', `${+v.toFixed(6)}px`);\n    else root.style.removeProperty('--am-marquee-pad');\n  }\n  /**\n   * Grows the space after the last item so that one group spans whole device pixels; true when\n   * the pad changed (then measure again: the period is always what the layout says).\n   */\n  function snapPad(g) {\n    if (!(g.P > 0)) return false;\n    const u = periodUnit(dpr() * g.k);\n    const extra = u * Math.ceil(g.P / u - 1e-6) - g.P;\n    if (extra < 1e-4) return false;\n    let next = pad + extra;\n    next -= u * Math.floor(next / u + 1e-9);\n    if (Math.abs(next - pad) < 1e-6) return false;\n    setPad(next);\n    return true;\n  }\n\n  let geo = measureGeo();\n  if (snapPad(geo)) geo = measureGeo();\n  setCopies(copiesFor(geo.W, geo.P));\n\n  // ---------------------------------------------------------------- state as functions of time\n  // X(t) = base + V * (integral of the rate since t0) + shift(t): the distance the band has gone\n  // in its direction. The band shows X modulo the width of one group.\n  let base = 0;\n  let V = speed0;\n  let flags0 = { hovered: false, focused: false, paused: !!o.paused, item: -1, glide: false };\n  let intents = [];\n  let loopDur = 0;\n  let rate;\n  let shift;\n  let icon;\n  let flagsS;\n  let endT = -Infinity;\n  let t0 = 0;\n\n  /** The integral of the rate from t0 to t, closed form. */\n  function integral(t) {\n    let sum = rate.initial * (t - t0);\n    let prev = rate.initial;\n    for (const e of rate.ev) {\n      if (e.t > t) break;\n      sum += (e.target - prev) * springArea(e.sp, t - e.t);\n      prev = e.target;\n    }\n    return sum;\n  }\n\n  /** The integral from t0 to forever, for a plan whose rate ends at 0 (where the band will rest). */\n  function restIntegral() {\n    let sum = -rate.initial * t0;\n    let prev = rate.initial;\n    for (const e of rate.ev) {\n      sum -= (e.target - prev) * (e.t + lagOf(e.sp));\n      prev = e.target;\n    }\n    return sum;\n  }\n\n  const X = (t) => base + V * integral(t) + shift.at(t);\n  /** x modulo one group, in [0, P); values a hair below P count as 0, so a loop closes exactly. */\n  const wrapP = (x) => {\n    const P = geo.P;\n    if (!(P > 0)) return 0;\n    const f = x - P * Math.floor(x / P);\n    return f < 1e-6 || P - f < 1e-6 ? 0 : f;\n  };\n  const wrapSym = (x) => (geo.P > 0 ? x - geo.P * Math.round(x / geo.P) : 0);\n\n  /**\n   * Where item j rests when it has keyboard focus: its middle in the middle of the band, clear\n   * of both fades. Never further left than half a group, so the copy before the original always\n   * fills the left side.\n   */\n  function wantOf(j) {\n    const it = geo.items[j];\n    return it ? Math.max(-geo.P / 2, it.x + it.w / 2 - geo.W / 2) : null;\n  }\n\n  /**\n   * How far into the original group the viewport starts at time t, px, in [-P, P). Normally in\n   * [0, P). While an item has keyboard focus, in a window of one group around the place the\n   * item glides to: the content repeats every P px, so the choice changes nothing on screen\n   * except that the original (which holds the focus) is the one shown, overshoot included.\n   */\n  function phase(t) {\n    const x = s * X(t);\n    const f = flagsS.at(t);\n    const want = f.focused && f.glide ? wantOf(f.item) : null;\n    const lo = want === null ? 0 : Math.max(-geo.P, want - geo.P / 2);\n    return lo + wrapP(x - lo);\n  }\n\n  /** The shift that makes the band come to rest with item j where wantOf(j) says. */\n  function glideFor(j) {\n    const want = wantOf(j);\n    if (want === null || !(geo.P > 0)) return 0;\n    const rest = base + V * restIntegral() + shift.target(Infinity);\n    return wrapSym(s * want - rest);\n  }\n\n  function apply(f, it) {\n    if (it.kind === 'hover') return { ...f, hovered: it.on };\n    if (it.kind === 'pause') return { ...f, paused: it.on };\n    return it.on ? { ...f, focused: true, item: it.item, glide: it.glide } : { ...f, focused: false, item: -1, glide: false };\n  }\n\n  /** Builds the rate, the shift, the icon and the flags from the input since t0. */\n  function plan() {\n    rate = track(stoppedBy(flags0) ? 0 : 1, START);\n    shift = track(0, GLIDE);\n    icon = track(flags0.paused ? 1 : 0, ICON);\n    flagsS = steps(flags0);\n    endT = -Infinity;\n    let f = flags0;\n    for (const it of intents) {\n      f = apply(f, it);\n      flagsS.set(it.t, f);\n      const want = stoppedBy(f) ? 0 : 1;\n      if (want !== rate.target(Infinity)) rate.to(it.t, want, want ? START : STOP);\n      if (it.kind === 'pause') icon.to(it.t, it.on ? 1 : 0);\n      if (it.kind === 'focus' && it.on && it.glide) {\n        // a scheduled demo plans every glide on the current layout; live glides keep their way\n        if (api.keep || it.delta === undefined) it.delta = glideFor(it.item);\n        if (it.delta) shift.to(it.t, shift.target(Infinity) + it.delta, GLIDE);\n      }\n      if (it.t > endT) endT = it.t;\n    }\n  }\n\n  /**\n   * With loop(duration), a scheduled demo ends where it started. With a keyboard glide in it,\n   * the band starts where the glide and the ride after it lead (the glide ends at a fixed\n   * place, so the start does not change the end). Without one, the speed is set so that the\n   * ride is a whole number of groups.\n   */\n  function fit() {\n    if (!loopDur || !(geo.P > 0)) return;\n    const t1 = t0 + loopDur;\n    V = speed0;\n    if (intents.some((it) => it.kind === 'focus' && it.on && it.glide)) {\n      plan();\n      const b = base + X(t1) - X(t0);\n      base = b - geo.P * Math.floor(b / geo.P);\n      plan();\n      return;\n    }\n    const I = integral(t1);\n    if (I > 0 && speed0 > 0) V = (Math.max(1, Math.round((speed0 * I) / geo.P)) * geo.P) / I;\n  }\n\n  // ---------------------------------------------------------------- painting\n  const focusTarget = (j) => {\n    const el = items[j];\n    if (!el) return null;\n    return el.matches && el.matches(FOCUSABLE) ? el : el.querySelector(FOCUSABLE) || el;\n  };\n  let ringed = null;\n\n  function paint(t, { reduced }) {\n    const f = flagsS.at(t);\n    const target = rate.target(t);\n    const still = rate.settled(t);\n    attr(root, 'data-motion', reduced ? 'reduced' : 'on');\n    attr(root, 'data-state', reduced ? 'stopped' : still ? (target > 0 ? 'running' : 'stopped') : target > 0 ? 'starting' : 'stopping');\n    attr(button, 'aria-label', f.paused ? labels.play : labels.pause);\n    attr(button, 'data-state', f.paused ? 'paused' : 'playing');\n    attr(button, 'hidden', reduced);\n    // the icon: 0 is two bars (pause), 1 a triangle (play); a spring between them, clamped to the two shapes\n    const p = String(+(reduced ? icon.target(t) : clamp01(icon.at(t))).toFixed(3));\n    if (button.style.getPropertyValue('--am-marquee-play') !== p) button.style.setProperty('--am-marquee-play', p);\n    for (const c of copies()) attr(c, 'hidden', reduced);\n    // the focus ring of a scheduled keyboard focus (a live one also has :focus-visible)\n    const ring = !reduced && f.focused && f.glide ? focusTarget(f.item) : null;\n    if (ring !== ringed) {\n      if (ringed) attr(ringed, 'data-focused', null);\n      if (ring) attr(ring, 'data-focused', true);\n      ringed = ring;\n    }\n    trackEl.style.transform = reduced || !(geo.P > 0) ? '' : `translate3d(${px(-(geo.P + phase(t)))}, 0, 0)`;\n  }\n\n  const settledAll = (t) => rate.settled(t) && shift.settled(t) && icon.settled(t);\n\n  function draw(t, st) {\n    paint(t, st);\n    // live: once every spring is home, fold the history into where the band is now\n    if (!api.keep && intents.length && t >= endT && settledAll(t)) {\n      const x = X(t);\n      base = geo.P > 0 ? x - geo.P * Math.floor(x / geo.P) : 0;\n      flags0 = flagsS.at(t);\n      t0 = t;\n      intents = [];\n      plan();\n    }\n  }\n\n  // Clocks. Input is timed when it happens (performance.now, never going back). Live frames are\n  // drawn at the time of the frame (document.timeline, the same for every callback of one frame),\n  // so a band at constant speed moves in even steps however much work ran before it in that\n  // frame; a frame time that is stale (nothing drawn for a while) is not used. A frame may be\n  // drawn a few ms before an input that came in during it: it then shows the state just before\n  // that input, which the springs continue from, so there is no jump. A given clock (demos,\n  // video) serves both.\n  const eventClock = o.clock || (() => performance.now() / 1000);\n  const frameClock = o.clock || (() => {\n    const now = performance.now() / 1000;\n    const tl = doc.timeline;\n    const ft = tl && typeof tl.currentTime === 'number' ? tl.currentTime / 1000 : NaN;\n    return ft > 0 && Math.abs(now - ft) < FRESH ? ft : now;\n  });\n\n  let onScreen = true;\n  const d = driver(draw, { clock: frameClock, reduced: o.reduced });\n  t0 = eventClock();\n  const running = (t) => rate.target(t) > 0 && geo.P > 0 && V > 0;\n  d.busy = (t) => onScreen && (t < endT || !settledAll(t) || running(t));\n  const commit = () => { paint(d.now(), { reduced: d.reduced }); d.kick(); };\n  const when = (opt) => (opt && opt.t !== undefined ? { t: opt.t, live: false } : { t: eventClock(), live: true });\n\n  // ---------------------------------------------------------------- input (in time order)\n  function push(it, opt) {\n    const { t, live } = when(opt);\n    it.t = t;\n    intents.push(it);\n    intents.sort((a, b) => a.t - b.t);\n    plan();\n    if (api.keep) fit();\n    commit();\n    return live;\n  }\n\n  /** The pointer is over the band (on) or has left it. opt: { t }. */\n  function hover(on, opt = {}) {\n    if (flagsS.last.hovered === !!on) return api;\n    push({ kind: 'hover', on: !!on }, opt);\n    return api;\n  }\n\n  /**\n   * Focus is on item i: the band stops and, with glide (default, what keyboard focus does),\n   * glides the item into the middle. opt: { t, glide }.\n   */\n  function focusItem(i, opt = {}) {\n    const glide = opt.glide !== false;\n    const f = flagsS.last;\n    if (i < 0 || i >= items.length || (f.focused && f.item === i && f.glide === glide)) return api;\n    push({ kind: 'focus', on: true, item: i, glide }, opt);\n    return api;\n  }\n\n  /** Focus has left the band. opt: { t }. */\n  function blur(opt = {}) {\n    if (!flagsS.last.focused) return api;\n    push({ kind: 'focus', on: false }, opt);\n    return api;\n  }\n\n  function setPaused(v, opt = {}) {\n    if (flagsS.last.paused === v) return api;\n    const live = push({ kind: 'pause', on: v }, opt);\n    if (live && o.onPauseChange) o.onPauseChange(v);\n    return api;\n  }\n\n  /** A scheduled demo of `duration` seconds ends where it started (see fit). */\n  function loop(duration) {\n    loopDur = Math.max(0, Number(duration) || 0);\n    plan();\n    fit();\n    commit();\n    return api;\n  }\n\n  /** Re-measure after a layout change: the copies follow the width; a demo is planned again. */\n  function measure() {\n    let g = measureGeo();\n    if (snapPad(g)) g = measureGeo();\n    // the period must be exact (every round would jump by the error); the rest may be rough\n    const near = (a, b, e = 0.5) => Math.abs(a - b) < e;\n    const same = near(g.W, geo.W) && near(g.P, geo.P, 1e-3) && near(g.k, geo.k, 1e-6) && g.items.length === geo.items.length\n      && g.items.every((it, i) => near(it.x, geo.items[i].x) && near(it.w, geo.items[i].w));\n    if (same) return api;\n    geo = g;\n    setCopies(copiesFor(geo.W, geo.P));\n    if (api.keep) { plan(); fit(); }\n    commit();\n    return api;\n  }\n\n  /** The content changed (items added or edited): copy it again and measure. */\n  function refresh() {\n    readItems();\n    clearCopies();\n    geo = measureGeo();\n    if (snapPad(geo)) geo = measureGeo();\n    setCopies(copiesFor(geo.W, geo.P));\n    if (api.keep) { plan(); fit(); }\n    commit();\n    return api;\n  }\n\n  // ---------------------------------------------------------------- live input\n  const listeners = [];\n  const on = (el, type, fn) => { el.addEventListener(type, fn); listeners.push([el, type, fn]); };\n  let pointerFocus = false;\n\n  on(button, 'click', () => setPaused(!flagsS.last.paused));\n  if (pauseOnHover) {\n    // Only the band itself counts: pointing at the header or the button leaves it running, so a\n    // click on Pause visibly stops it. A touch has no hover; the button is there for touch screens.\n    on(viewport, 'pointerenter', (e) => { if (e.pointerType !== 'touch') hover(true); });\n    on(viewport, 'pointerleave', (e) => { if (e.pointerType !== 'touch') hover(false); });\n  }\n  on(viewport, 'pointerdown', () => { pointerFocus = true; });\n  on(viewport, 'pointerup', () => { pointerFocus = false; });\n  on(viewport, 'focusin', (e) => {\n    const glide = !pointerFocus;\n    pointerFocus = false;\n    const i = items.findIndex((it) => it.contains(e.target));\n    if (i >= 0) focusItem(i, { glide });\n  });\n  on(viewport, 'focusout', (e) => {\n    const next = e.relatedTarget;\n    if (next && viewport.contains(next)) return;\n    blur();\n  });\n  // Focus must not scroll the viewport (the glide shows the item); with reduced motion it may.\n  on(viewport, 'scroll', () => {\n    if (d.reduced) return;\n    if (viewport.scrollLeft) viewport.scrollLeft = 0;\n    if (viewport.scrollTop) viewport.scrollTop = 0;\n  });\n\n  let ro = null;\n  if (typeof ResizeObserver === 'function') {\n    ro = new ResizeObserver(() => measure());\n    ro.observe(viewport);\n    ro.observe(group);\n  }\n  let io = null;\n  if (typeof IntersectionObserver === 'function') {\n    // off screen nothing is drawn; the position stays a function of time, so it is right on return\n    io = new IntersectionObserver((entries) => {\n      for (const e of entries) onScreen = e.isIntersecting;\n      if (onScreen) d.kick();\n    });\n    io.observe(root);\n  }\n\n  const api = {\n    root,\n    /** The row above the band that holds the pause button (null when your own button sits elsewhere). */\n    header,\n    viewport,\n    track: trackEl,\n    button,\n    driver: d,\n    keep: false,\n    /** Every group in the order of the track: the copy before the original, the original, the copies after. */\n    get groups() { return lead ? [lead, group, ...after] : [group, ...after]; },\n    /** The original group (the one screen readers and the Tab key use). */\n    group,\n    get items() { return items.slice(); },\n    hover,\n    focusItem,\n    blur,\n    pause: (opt) => setPaused(true, opt),\n    play: (opt) => setPaused(false, opt),\n    toggle: (opt) => setPaused(!flagsS.last.paused, opt),\n    /** Paused by the button (or pause()) now, or at time t of a scheduled timeline. */\n    paused: (t) => (t === undefined ? flagsS.last : flagsS.at(t)).paused,\n    loop,\n    measure,\n    refresh,\n    /** The x offset of the track at time t (now by default), px (the copy before the original included). */\n    offset: (t = d.now()) => (d.reduced || !(geo.P > 0) ? 0 : -(geo.P + phase(t))),\n    /** Speed at time t (now by default), px per second in the direction of travel. */\n    speed: (t = d.now()) => (d.reduced ? 0 : V * rate.at(t)),\n    /** Width of one group, px: the band repeats after this distance. */\n    period: () => geo.P,\n    /** The extra space after the last item that puts one group on whole device pixels, px. */\n    pad: () => pad,\n    seek: (t) => paint(t, { reduced: d.reduced }),\n    settled: (t) => t >= endT && settledAll(t),\n    destroy() {\n      d.stop();\n      if (ro) ro.disconnect();\n      if (io) io.disconnect();\n      for (const [el, type, fn] of listeners) el.removeEventListener(type, fn);\n      clearCopies();\n      setPad(0);\n      trackEl.style.transform = '';\n      if (ringed) attr(ringed, 'data-focused', null);\n    },\n  };\n  plan();\n  paint(d.now(), { reduced: d.reduced });\n  if (!d.reduced) d.kick();\n  return api;\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/lib/arling-motion-marquee.d.ts",
      "content": "// ARLing Motion: marquee. MIT licence, https://arling.sk/motion/ . Types for the plain JavaScript file next to this one.\nexport declare const SPEED: any;\nexport declare const STOP: any;\nexport declare const START: any;\nexport declare const GLIDE: any;\nexport declare const ICON: any;\nexport declare const DIRECTIONS: any;\nexport declare const LABELS: any;\nexport declare const TEXTS: any;\nexport declare const MAX_PAD: any;\nexport declare const lagOf: any;\nexport declare function springArea(...args: any[]): any;\nexport declare function copiesFor(...args: any[]): any;\nexport declare function periodUnit(...args: any[]): any;\nexport declare function createMarquee(...args: any[]): any;\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/arling/ui/motion-marquee.tsx",
      "content": "'use client';\n// ARLing Motion: Marquee for React. A thin wrapper: the vanilla component owns the band's\n// position (a function of time), the copies, the stop and start spring, the keyboard glide,\n// the soft edges and the pause button in the header row above the band. React renders the\n// original items and the optional heading; after each render the component copies the items\n// again. In the registry the core lives at '@/lib/arling-motion' and this logic at\n// '@/lib/arling-motion-marquee'.\nimport * as React from 'react';\nimport { createMarquee } from '@/lib/arling-motion-marquee';\n\ntype MarqueeApi = { refresh: () => unknown; pause: () => unknown; play: () => unknown; paused: () => boolean; destroy: () => void };\n\nconst cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(' ');\n\nexport interface MarqueeProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** A short title in the row above the band, left of the pause button; it also names the band for screen readers. */\n  heading?: React.ReactNode;\n  /** Speed in px per second (default 40). */\n  speed?: number;\n  /** Which way the band moves (default left). */\n  direction?: 'left' | 'right';\n  /** Stop while the pointer is over the band (default true). */\n  pauseOnHover?: boolean;\n  /** Controlled paused state of the pause button (optional). */\n  paused?: boolean;\n  onPausedChange?: (paused: boolean) => void;\n  /** aria-label of the button while the band runs and while it is paused (each should begin with the word shown). */\n  pauseLabel?: string;\n  playLabel?: string;\n  /** The words on the button (default Pause and Play). */\n  pauseText?: string;\n  playText?: string;\n  reducedMotion?: boolean;\n}\n\nexport function Marquee({\n  heading,\n  speed,\n  direction,\n  pauseOnHover = true,\n  paused,\n  onPausedChange,\n  pauseLabel,\n  playLabel,\n  pauseText,\n  playText,\n  reducedMotion,\n  className,\n  children,\n  ...rest\n}: MarqueeProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const apiRef = React.useRef<MarqueeApi | null>(null);\n  const changeRef = React.useRef(onPausedChange);\n  changeRef.current = onPausedChange;\n  const headingId = React.useId();\n  const named = rest['aria-label'] !== undefined || rest['aria-labelledby'] !== undefined;\n\n  React.useEffect(() => {\n    if (!ref.current) return;\n    const labels: { pause?: string; play?: string } = {};\n    if (pauseLabel) labels.pause = pauseLabel;\n    if (playLabel) labels.play = playLabel;\n    const texts: { pause?: string; play?: string } = {};\n    if (pauseText) texts.pause = pauseText;\n    if (playText) texts.play = playText;\n    const api = createMarquee({\n      root: ref.current,\n      speed,\n      direction,\n      pauseOnHover,\n      paused: !!paused,\n      labels,\n      texts,\n      reduced: reducedMotion,\n      onPauseChange: (v: boolean) => changeRef.current?.(v),\n    }) as unknown as MarqueeApi;\n    apiRef.current = api;\n    return () => {\n      api.destroy();\n      apiRef.current = null;\n    };\n    // built once per option; later paused values go through pause() and play()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [speed, direction, pauseOnHover, pauseLabel, playLabel, pauseText, playText, reducedMotion]);\n\n  // new or changed items: copy the content again\n  React.useEffect(() => {\n    apiRef.current?.refresh();\n  }, [children]);\n\n  React.useEffect(() => {\n    const api = apiRef.current;\n    if (!api || paused === undefined || paused === api.paused()) return;\n    if (paused) api.pause();\n    else api.play();\n  }, [paused]);\n\n  return (\n    <div\n      ref={ref}\n      className={cx('am-marquee', className)}\n      role=\"group\"\n      aria-labelledby={heading && !named ? headingId : undefined}\n      {...rest}\n    >\n      <div className=\"am-marquee-header\">\n        {heading ? <span id={headingId} className=\"am-marquee-title\">{heading}</span> : null}\n        <button type=\"button\" className=\"am-marquee-toggle\" />\n      </div>\n      <div className=\"am-marquee-viewport\">\n        <div className=\"am-marquee-track\">\n          <ul className=\"am-marquee-group\">{children}</ul>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function MarqueeItem({ className, ...rest }: React.LiHTMLAttributes<HTMLLIElement>) {\n  return <li className={cx('am-marquee-item', className)} {...rest} />;\n}\n\nexport default Marquee;\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@layer components": {
      ".am-marquee": {
        "--am-marquee-gap": "0.75rem",
        "--am-marquee-fade": "clamp(2.5rem, 20%, 6rem)",
        "display": "grid",
        "grid-template-columns": "minmax(0, 1fr)",
        "gap": "0.5rem",
        "box-sizing": "border-box",
        "width": "100%",
        "min-width": "0"
      },
      ".am-marquee-header": {
        "display": "flex",
        "align-items": "center",
        "justify-content": "space-between",
        "gap": "0.75rem",
        "min-width": "0"
      },
      ".am-marquee-title": {
        "min-width": "0",
        "overflow": "hidden",
        "color": "var(--muted-foreground, #737373)",
        "font-size": "0.875rem",
        "line-height": "1.25rem",
        "text-overflow": "ellipsis",
        "white-space": "nowrap"
      },
      ".am-marquee-viewport": {
        "--am-marquee-mask": "linear-gradient(to right, transparent 0, rgb(0 0 0 / 0.1) calc(var(--am-marquee-fade) * 0.2), rgb(0 0 0 / 0.35) calc(var(--am-marquee-fade) * 0.4), rgb(0 0 0 / 0.65) calc(var(--am-marquee-fade) * 0.6), rgb(0 0 0 / 0.9) calc(var(--am-marquee-fade) * 0.8), #000 var(--am-marquee-fade), #000 calc(100% - var(--am-marquee-fade)), rgb(0 0 0 / 0.9) calc(100% - var(--am-marquee-fade) * 0.8), rgb(0 0 0 / 0.65) calc(100% - var(--am-marquee-fade) * 0.6), rgb(0 0 0 / 0.35) calc(100% - var(--am-marquee-fade) * 0.4), rgb(0 0 0 / 0.1) calc(100% - var(--am-marquee-fade) * 0.2), transparent 100%)",
        "position": "relative",
        "min-width": "0",
        "overflow": "hidden",
        "padding": "0.375rem 0",
        "-webkit-mask-image": "var(--am-marquee-mask)",
        "mask-image": "var(--am-marquee-mask)"
      },
      ".am-marquee .am-marquee-viewport": {
        "overflow": "clip"
      },
      ".am-marquee[data-motion=\"reduced\"] .am-marquee-viewport": {
        "overflow-x": "auto",
        "-webkit-mask-image": "none",
        "mask-image": "none"
      },
      ".am-marquee-track": {
        "display": "flex",
        "width": "max-content",
        "will-change": "transform"
      },
      ".am-marquee-group": {
        "display": "flex",
        "flex": "none",
        "align-items": "center",
        "gap": "var(--am-marquee-gap)",
        "margin": "0",
        "padding": "0 calc(var(--am-marquee-gap) + var(--am-marquee-pad, 0px)) 0 0",
        "list-style": "none"
      },
      ".am-marquee-group[hidden]": {
        "display": "none"
      },
      ".am-marquee-item": {
        "flex": "none",
        "white-space": "nowrap"
      },
      ".am-marquee-group :focus-visible, .am-marquee-group [data-focused]": {
        "outline": "2px solid var(--ring, #a3a3a3)",
        "outline-offset": "2px"
      },
      ".am-marquee-toggle": {
        "display": "inline-flex",
        "align-items": "center",
        "flex": "none",
        "gap": "0.375rem",
        "box-sizing": "border-box",
        "height": "1.75rem",
        "margin-left": "auto",
        "padding": "0 0.625rem 0 0.5625rem",
        "border": "1px solid var(--border, #e5e5e5)",
        "border-radius": "9999px",
        "background": "var(--background, #ffffff)",
        "color": "var(--muted-foreground, #737373)",
        "font": "inherit",
        "font-size": "0.75rem",
        "font-weight": "500",
        "line-height": "1",
        "white-space": "nowrap",
        "cursor": "pointer",
        "-webkit-tap-highlight-color": "transparent"
      },
      ".am-marquee-toggle:hover": {
        "background": "var(--accent, #f5f5f5)",
        "color": "var(--foreground, #0a0a0a)"
      },
      ".am-marquee-toggle:focus-visible": {
        "outline": "2px solid var(--ring, #a3a3a3)",
        "outline-offset": "2px"
      },
      ".am-marquee-toggle[hidden]": {
        "display": "none"
      },
      ".am-marquee-toggle-icon": {
        "position": "relative",
        "flex": "none",
        "width": "0.625rem",
        "height": "0.625rem",
        "transform": "translateX(calc(var(--am-marquee-play, 0) * 0.0625rem))"
      },
      ".am-marquee-toggle-icon::before, .am-marquee-toggle-icon::after": {
        "content": "\"\"",
        "position": "absolute",
        "inset": "0",
        "background": "currentColor"
      },
      ".am-marquee-toggle-icon::before": {
        "clip-path": "polygon(0 0, calc(35% + 17% * var(--am-marquee-play, 0)) calc(26% * var(--am-marquee-play, 0)), calc(35% + 17% * var(--am-marquee-play, 0)) calc(100% - 26% * var(--am-marquee-play, 0)), 0 100%)"
      },
      ".am-marquee-toggle-icon::after": {
        "clip-path": "polygon(calc(65% - 15% * var(--am-marquee-play, 0)) calc(25% * var(--am-marquee-play, 0)), 100% calc(50% * var(--am-marquee-play, 0)), 100% calc(100% - 50% * var(--am-marquee-play, 0)), calc(65% - 15% * var(--am-marquee-play, 0)) calc(100% - 25% * var(--am-marquee-play, 0)))"
      },
      ".am-marquee-toggle-text": {
        "display": "grid"
      },
      ".am-marquee-toggle-text > span": {
        "grid-area": "1 / 1"
      },
      ".am-marquee-toggle[data-state=\"playing\"] .am-marquee-toggle-play, .am-marquee-toggle[data-state=\"paused\"] .am-marquee-toggle-pause": {
        "visibility": "hidden"
      }
    }
  },
  "docs": "Marquee from ARLing Motion (MIT).\nReact: import { Marquee, MarqueeItem } from \"@/components/ui/motion-marquee\".\nThe logic is in lib/arling-motion-marquee.js (typed by the .d.ts next to it) and works without React: createMarquee on your own markup.\nStyles were added to your global CSS under @layer components (Tailwind v4).\nWith Tailwind v3 or no Tailwind, add https://arling.sk/motion/components/marquee/marquee.css to your styles instead.\nLive demo and docs: https://arling.sk/motion/#marquee",
  "categories": [
    "motion",
    "marquee",
    "ticker",
    "infinite-slider",
    "logos"
  ]
}
