{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gather",
  "title": "Gather",
  "description": "A line whose letters, words or rows start as dust, as the mirrored forme or wound in a coil, and settle into place as it scrolls into view, or step by step with the scroll, then give way to the real text.",
  "dependencies": [
    "@chenglou/pretext"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/gather.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\n/** The attributes on <html> that can change the face. Not class: smooth scrolling toggles one on every scroll. */\nconst FACE = [\"data-pair\", \"data-scheme\", \"data-mode\", \"data-key\"]\n\ntype GatherProps = Omit<React.ComponentProps<\"p\">, \"children\"> & {\n  /** The line, as plain text: pretext measures where each letter belongs. */\n  children: string\n  /** The element. Pass a dynamic class (`ot-f`, `ot-mf`…) for the size. */\n  as?: \"h2\" | \"h3\" | \"p\"\n  /** Where the letters start: dust scattered round their places, the forme (the line mirrored, as type stands before it is printed), or a coil (the line wound up at its start). */\n  variant?: \"dust\" | \"forme\" | \"coil\"\n  /** What travels as one piece: each letter, each word, or each line (a slug, as a Linotype casts it). */\n  by?: \"letter\" | \"word\" | \"line\"\n  /** Tie the settle to the scroll: the line gathers as it rises up the view, stops when the scroll stops, and scatters again when you scroll back. */\n  scrub?: boolean\n}\n\n/** The same letter always starts in the same place: a small hash of its index. */\nconst rand = (i: number, salt: number) => {\n  const s = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453\n  return s - Math.floor(s)\n}\n\n/**\n * A line whose letters start as dust (or mirrored, as the forme, or wound in a coil) and settle into place. Pretext lays the line out and measures\n * every letter's x, so the settled state is the real layout to the pixel; then the letters give way\n * to the real text, which is what you select and copy. The text stays in the DOM the whole time, so a\n * reader of the page always gets it. Nothing runs without script, or under reduced motion: it's just\n * the line.\n */\nfunction Gather({ children: text, as = \"p\", variant = \"dust\", by = \"letter\", scrub = false, className, ref: forwardedRef, ...props }: GatherProps) {\n  const Tag = as as \"p\"\n  const ref = React.useRef<HTMLParagraphElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el || matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return // renders settled\n    let cancelled = false\n    const off: (() => void)[] = []\n    // Whatever runs, letting go restores the plain line.\n    const finish = () => {\n      cancelled = true\n      off.splice(0).forEach((f) => f())\n    }\n\n    // The real text steps aside (still there for readers) while the letters are measured.\n    el.dataset.phase = \"wait\"\n    const layer = document.createElement(\"span\")\n    layer.setAttribute(\"aria-hidden\", \"true\")\n    layer.className = \"ot-gather-glyphs\"\n    el.append(layer)\n    off.push(() => {\n      layer.remove()\n      delete el.dataset.phase\n    })\n\n    ;(async () => {\n      let lib: typeof import(\"@chenglou/pretext\")\n      try {\n        lib = await import(\"@chenglou/pretext\")\n      } catch {\n        return finish() // the plain line stays\n      }\n      if (cancelled) return\n      const graphemes = new Intl.Segmenter(undefined, { granularity: \"grapheme\" })\n\n      let width = 0\n      async function lay() {\n        if (cancelled) return\n        const style = getComputedStyle(el!)\n        const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`\n        try {\n          await document.fonts.load(font, text)\n        } catch {\n          if (!cancelled) finish() // the plain line stays if the face cannot load\n          return\n        }\n        if (cancelled) return\n        const W = el!.clientWidth\n        if (!W || (!scrub && !el!.dataset.phase)) return // hidden or already arrived (a scrub never arrives for good)\n        width = W\n        const size = parseFloat(style.fontSize)\n        const lh = parseFloat(style.lineHeight) || size * 1.2\n        const letterSpacing = parseFloat(style.letterSpacing) || 0 // \"normal\" is 0\n        const measure = (s: string) => lib.measureNaturalWidth(lib.prepareWithSegments(s, font, { letterSpacing }))\n        const prepared = lib.prepareWithSegments(text, font, { letterSpacing })\n        const rtl = style.direction === \"rtl\"\n        const align = style.textAlign === \"start\" ? (rtl ? \"right\" : \"left\") : style.textAlign === \"end\" ? (rtl ? \"left\" : \"right\") : style.textAlign\n\n        const rows: { words: string; lw: number; inset: number }[] = []\n        let cursor = { segmentIndex: 0, graphemeIndex: 0 }\n        for (let row = 0; row < 200; row++) {\n          const line = lib.layoutNextLine(prepared, cursor, W)\n          if (!line) break\n          cursor = line.end\n          const words = line.text.trimEnd()\n          const lw = measure(words)\n          rows.push({ words, lw, inset: align === \"center\" ? (W - lw) / 2 : align === \"right\" ? W - lw : 0 })\n        }\n\n        // The coil: the whole text wound into one spiral beside the first row's start, its first letter on the\n        // outermost turn, a little more than a letter's height between turns.\n        const pitch = 1.15 * size, inner = size\n        const R0 = Math.sqrt((rows.reduce((sum, r) => sum + r.lw + size / 3, 0) * pitch) / Math.PI + inner ** 2)\n        const head = rows[0] ? (rtl ? rows[0].inset + rows[0].lw : rows[0].inset) : 0\n        const cx = head + (rtl ? -R0 : R0), cy = (rows.length * lh) / 2\n        let theta = 0, r = R0, u0 = 0, along = 0\n\n        // The pieces of a row: its letters, its words (spaces between them), or the whole row.\n        const pieces = (words: string) =>\n          by === \"line\" ? [words] : by === \"word\" ? words.split(/(\\s+)/) : Array.from(graphemes.segment(words), (s) => s.segment)\n        const glyphs: HTMLSpanElement[] = []\n        rows.forEach(({ words, lw, inset }, row) => {\n          let before = \"\"\n          for (const g of pieces(words)) {\n            if (g.trim()) {\n              // x is what comes before this piece, kerning included: the width up to and with it, less its own.\n              const gw = measure(g)\n              const x = inset + measure(before + g) - gw\n              const n = glyphs.length\n              let from: string\n              if (variant === \"forme\") from = `--dx:${2 * inset + lw - 2 * x - gw}px;--dy:0px;--sx:-1` // the mirror of its place in the row\n              else if (variant === \"coil\") {\n                const u = along + (rtl ? inset + lw - x - gw / 2 : x - inset + gw / 2) // how far along the text, from its start\n                theta += (u - u0) / r\n                u0 = u\n                r = Math.max(inner, R0 - (pitch * theta) / (2 * Math.PI))\n                const px = cx + (rtl ? -1 : 1) * r * Math.sin(theta), py = cy + r * Math.cos(theta)\n                from = `--dx:${px - x - gw / 2}px;--dy:${py - row * lh - lh / 2}px;--r:${(rtl ? 1 : -1) * theta}rad`\n              } else from = `--dx:${Math.max(-x, Math.min((rand(n, 1) * 2 - 1) * 1.2 * size, W - x - (by === \"letter\" ? size / 2 : gw)))}px;--dy:${(rand(n, 2) * 2 - 1) * 0.8 * size}px`\n              const glyph = document.createElement(\"span\")\n              glyph.textContent = g\n              glyph.style.cssText = `left:${x}px;top:${row * lh}px;--n:${n};${from}`\n              glyphs.push(glyph)\n            }\n            before += g\n          }\n          along += lw + size / 3\n        })\n        // In a scrub each piece's place in the order, 0 to 1, says when in the scroll it sets off.\n        glyphs.forEach((glyph, n) => glyph.style.setProperty(\"--s\", String(glyphs.length > 1 ? n / (glyphs.length - 1) : 0)))\n        layer.replaceChildren(...glyphs)\n        if (scrub) scrubbed()\n        else if (el!.dataset.phase === \"wait\") el!.dataset.phase = \"dust\"\n        else if (el!.dataset.phase === \"gather\") finish() // laid out again mid-flight: just arrive\n      }\n\n      // The scrub: how far the line has risen through the view, 0 as its top comes in at the foot, 1 once it is\n      // two fifths down from the top (or as far as the page can scroll). Read from the real layout, so it follows\n      // any smooth scroller that moves the page itself.\n      let frame = 0\n      function scrubbed() {\n        if (cancelled) return\n        const top = el!.getBoundingClientRect().top + scrollY, h = innerHeight\n        const from = Math.max(0, top - h), to = Math.min(document.documentElement.scrollHeight - h, top - 0.4 * h)\n        const p = to <= from ? 1 : Math.min(1, Math.max(0, (scrollY - from) / (to - from)))\n        el!.style.setProperty(\"--p\", String(p))\n        if (p < 1) el!.dataset.phase = \"scrub\"\n        else delete el!.dataset.phase // arrived: the real text, to select and copy, until you scroll back\n      }\n      // Asked for from the scroll event, the frame runs after a smooth scroller's own frame has moved the page, so the\n      // letters and the page move in the same frame.\n      const onScroll = () => {\n        if (cancelled) return\n        cancelAnimationFrame(frame)\n        frame = requestAnimationFrame(scrubbed)\n      }\n\n      await lay()\n      if (cancelled || (!scrub && !el.dataset.phase)) return\n\n      const resized = new ResizeObserver(() => (el.clientWidth !== width ? lay() : scrub && onScroll()))\n      resized.observe(el)\n      // A change of pair or scheme on <html> can change the face: measure again.\n      const restyled = new MutationObserver(() => lay())\n      restyled.observe(document.documentElement, { attributeFilter: FACE })\n      off.push(() => {\n        resized.disconnect()\n        restyled.disconnect()\n      })\n\n      if (scrub) {\n        addEventListener(\"scroll\", onScroll, { passive: true })\n        addEventListener(\"resize\", onScroll)\n        off.push(() => {\n          cancelAnimationFrame(frame)\n          removeEventListener(\"scroll\", onScroll)\n          removeEventListener(\"resize\", onScroll)\n        })\n        return\n      }\n\n      // The last letter to arrive gives the real text back.\n      const arrived = (e: TransitionEvent) => {\n        if (e.target === layer.lastElementChild) finish() // any of its properties: they share one duration\n      }\n      layer.addEventListener(\"transitionend\", arrived)\n\n      const settle = () => {\n        if (cancelled || el.dataset.phase !== \"dust\") return\n        void layer.offsetWidth // the dust has been drawn once, so it can travel\n        el.dataset.phase = \"gather\"\n      }\n      const seen = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && settle(), { rootMargin: \"0px 0px -12% 0px\" })\n      seen.observe(el)\n      el.addEventListener(\"pointerenter\", settle)\n      off.push(() => {\n        seen.disconnect()\n        el.removeEventListener(\"pointerenter\", settle)\n        layer.removeEventListener(\"transitionend\", arrived)\n      })\n    })()\n\n    return () => {\n      finish()\n    }\n  }, [text, variant, by, scrub])\n\n  return (\n    <Tag ref={composedRef} data-slot=\"gather\" data-variant={variant === \"dust\" ? undefined : variant} data-by={by === \"letter\" ? undefined : by} className={cn(\"ot-gather\", className)} {...props}>\n      <span className=\"ot-gather-text\">{text}</span>\n    </Tag>\n  )\n}\n\nexport { Gather, type GatherProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/gather.css",
      "content": "@layer components {\n  /* ── ot-gather ── a line whose letters start as dust (or the forme, or a coil) and settle into place. Pretext puts each\n     letter where the real text would have it; the last to land gives the real text back. Until\n     script runs, or under reduced motion, it's just the line. */\n  .ot-gather { position: relative; margin: 0; }\n  .ot-gather[data-slot=\"gather\"] { text-wrap: wrap; } /* pretext breaks greedily: it wraps so, even where a parent balances its headings (prose) */\n  .ot-gather[data-phase] > .ot-gather-text { opacity: 0; } /* still there for readers, and it holds the height */\n  .ot-gather-glyphs { position: absolute; inset: 0; pointer-events: none; user-select: none; }\n  .ot-gather-glyphs > span {\n    position: absolute; white-space: pre; opacity: 1; translate: 0 0; rotate: 0rad; scale: 1;\n    transition-property: translate, rotate, scale, opacity, color; transition-duration: var(--ot-andante); transition-timing-function: var(--ot-exhale);\n    transition-delay: min(calc(var(--n) * var(--step, var(--ot-arpeggio))), 1s); /* a long line arrives in a second, not a minute */\n  }\n  /* A word is set down a few letters' time after the last; a line, as long again (the compositor's pace, not the reader's). */\n  .ot-gather[data-by=\"word\"] { --step: calc(4 * var(--ot-arpeggio)); }\n  .ot-gather[data-by=\"line\"] { --step: calc(8 * var(--ot-arpeggio)); }\n  .ot-gather[data-phase=\"dust\"] .ot-gather-glyphs > span {\n    opacity: 0.18; /* pp: the quietest a thing can be and still be seen */\n    translate: var(--dx) var(--dy);\n  }\n  /* forme: the line as type stands in the chase before it is printed, mirrored and not yet inked. Each letter turns\n     over as it crosses to its place, and inks as it lands. */\n  .ot-gather[data-variant=\"forme\"][data-phase=\"dust\"] .ot-gather-glyphs > span { opacity: 1; color: var(--ot-pencil); scale: var(--sx) 1; }\n  /* coil: the line wound up at its start like a ribbon, outermost turn first; it unwinds from its free end. */\n  .ot-gather[data-variant=\"coil\"][data-phase=\"dust\"] .ot-gather-glyphs > span { opacity: 0.5; rotate: var(--r); }\n\n  /* scrub: the settle follows the scroll. --p (0 to 1) is how far the line has risen through the view; each piece\n     sets off when --p passes its place in the order (--s, 0 to 1) and lands 0.4 of the scroll later. Nothing\n     eases or lingers: when the scroll stops, so do the letters, and scrolling back scatters them again. */\n  .ot-gather[data-phase=\"scrub\"] .ot-gather-glyphs > span {\n    --q: clamp(0, calc((var(--p) - var(--s) * 0.6) / 0.4), 1);\n    transition: none;\n    translate: calc(var(--dx) * (1 - var(--q))) calc(var(--dy) * (1 - var(--q)));\n    opacity: calc(0.18 + 0.82 * var(--q));\n  }\n  .ot-gather[data-variant=\"forme\"][data-phase=\"scrub\"] .ot-gather-glyphs > span {\n    opacity: 1; scale: calc(var(--sx) + (1 - var(--sx)) * var(--q)) 1;\n    color: color-mix(in oklab, currentColor calc(var(--q) * 100%), var(--ot-pencil));\n  }\n  .ot-gather[data-variant=\"coil\"][data-phase=\"scrub\"] .ot-gather-glyphs > span { opacity: calc(0.5 + 0.5 * var(--q)); rotate: calc(var(--r) * (1 - var(--q))); }\n  .ot-gather:not([data-phase]) > .ot-gather-glyphs { visibility: hidden; } /* a scrub that has arrived keeps its letters, out of sight, for the way back */\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/gather.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/gather.css\"": {}
  },
  "meta": {
    "movement": "II",
    "underneath": "hook",
    "contract": "ot-gather"
  },
  "type": "registry:ui"
}
