{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-expand",
  "title": "Scroll expand",
  "description": "A plate cropped by four corner marks that open with the scroll, from a small mark to the full measure, and stop when you stop.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/corners.json",
    "https://thedirectors.agency/ui/r/meta.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/scroll-expand.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { Corners } from \"@/components/ui/corners\"\nimport { Meta } from \"@/components/ui/meta\"\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype ScrollExpandProps = Omit<React.ComponentProps<\"figure\">, \"children\"> & {\n  /** What the plate holds. It is all there for readers from the start; only the view of it is cropped. */\n  children: React.ReactNode\n  /** The words in the meta row under the plate, held apart by hairlines. The row grows with the crop. */\n  caption?: React.ReactNode\n  /** mark: the crop opens from a small square in the middle. horizon: from a hairline across the whole measure, up and down. */\n  variant?: \"mark\" | \"horizon\"\n  /** Pins how far it has opened, 0 to 1, instead of following the scroll. */\n  progress?: number\n}\n\n/**\n * A plate cropped by four corner marks that open as the page scrolls, from a small mark to the full\n * measure. It is scrubbed: it opens only as far as the scroll has gone, stops when the scroll stops and\n * closes again when you scroll back. The share of the measure it has reached is read out at the end of\n * the caption. Under reduced motion, or without script, it stands open.\n */\nfunction ScrollExpand({ children, caption, variant = \"mark\", progress, className, ref: forwardedRef, ...props }: ScrollExpandProps) {\n  const ref = React.useRef<HTMLElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const share = React.useRef<HTMLSpanElement>(null)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const set = (p: number) => {\n      el.style.setProperty(\"--p\", p.toFixed(4))\n      if (share.current) share.current.textContent = p.toFixed(2)\n    }\n    if (progress !== undefined) return set(Math.min(1, Math.max(0, progress)))\n    if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return set(1)\n\n    // 0 as the plate's top comes in at the foot of the view, 1 once its middle is at the middle of the view\n    // (or as far as the page can scroll). Read from the real layout, so it follows a smooth scroller that\n    // moves the page itself.\n    let frame = 0\n    const read = () => {\n      const box = el.getBoundingClientRect(), h = innerHeight\n      const top = box.top + scrollY\n      const from = Math.max(0, top - h), to = Math.min(document.documentElement.scrollHeight - h, top + box.height / 2 - h / 2)\n      set(to <= from ? 1 : Math.min(1, Math.max(0, (scrollY - from) / (to - from))))\n    }\n    // Asked for from the scroll event, the frame runs after a smooth scroller's own frame has moved the page,\n    // so the crop and the page move in the same frame.\n    const onScroll = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(read)\n    }\n    read()\n    addEventListener(\"scroll\", onScroll, { passive: true })\n    addEventListener(\"resize\", onScroll)\n    return () => {\n      cancelAnimationFrame(frame)\n      removeEventListener(\"scroll\", onScroll)\n      removeEventListener(\"resize\", onScroll)\n    }\n  }, [progress])\n\n  return (\n    <figure ref={composedRef} data-slot=\"scroll-expand\" data-variant={variant === \"mark\" ? undefined : variant} className={cn(\"ot-expand\", className)} {...props}>\n      <Corners className=\"ot-expand-frame\">\n        <div className=\"ot-expand-plate\">{children}</div>\n      </Corners>\n      <figcaption className=\"ot-expand-caption\">\n        <Meta>\n          {React.Children.toArray(caption)}\n          <span ref={share} className=\"ot-expand-share\" aria-hidden=\"true\">1.00</span>\n        </Meta>\n      </figcaption>\n    </figure>\n  )\n}\n\nexport { ScrollExpand, type ScrollExpandProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/scroll-expand.css",
      "content": "@layer components {\n  /* ── ot-expand ── a plate cropped by four corner marks that open with the scroll, from a small mark to the measure.\n     --p (0 to 1) is set by the hook from the real scroll position; without it the plate stands open. The crop is a\n     clip-path, so the whole plate is in the flow and read from the start; the corner marks stand a step outside it,\n     as a printer's crop marks stand outside the trim. Nothing eases: the crop is exactly where the scroll is. */\n  .ot-expand {\n    --q: calc(1 - var(--p, 1));\n    --m: var(--ot-expand-mark, var(--ot-space-8));\n    margin: 0; display: grid; gap: var(--ot-space-4); min-width: 0;\n  }\n  .ot-expand-frame {\n    --cx: calc(var(--q) * max(0px, 50% - var(--m) / 2));\n    --cy: var(--cx);\n    --ot-corner: var(--ot-space-4);\n    --ot-corner-inset: calc(var(--cy) - var(--ot-space-2)) calc(var(--cx) - var(--ot-space-2));\n    min-width: 0;\n  }\n  .ot-expand-plate { clip-path: inset(var(--cy) var(--cx)); }\n  /* The caption's row is as wide as the crop (never narrower than it can read), so its hairlines draw out as it opens,\n     the rule of SHAPES / GRADIENTS carrying the date and the name. The share of the measure closes the row. */\n  .ot-expand-caption { inline-size: max(min(100%, 20rem), calc(var(--m) + var(--p, 1) * (100% - var(--m)))); justify-self: center; }\n  .ot-expand-share { color: var(--ot-pencil); font-variant-numeric: tabular-nums lining-nums; }\n\n  /* horizon: after Eclipse, where the disc stands on one line. The crop starts as a hairline across the whole measure\n     and opens up and down from it; the horizon itself is drawn while it's shut and fades as the plate opens. */\n  .ot-expand[data-variant=\"horizon\"] .ot-expand-frame { --cx: 0px; --cy: calc(var(--q) * (50% - var(--ot-hairline) / 2)); }\n  .ot-expand[data-variant=\"horizon\"] .ot-expand-frame::before {\n    content: \"\"; position: absolute; inset-inline: 0; top: calc(50% - var(--ot-hairline) / 2); block-size: var(--ot-hairline);\n    background: var(--ot-rule-strong); opacity: calc(1 - min(1, 4 * var(--p, 1))); pointer-events: none; /* gone by a quarter open */\n  }\n  .ot-expand[data-variant=\"horizon\"] .ot-expand-caption { inline-size: 100%; }\n  @media (forced-colors: active) { .ot-expand[data-variant=\"horizon\"] .ot-expand-frame::before { background: CanvasText; } }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/scroll-expand.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/scroll-expand.css\"": {}
  },
  "meta": {
    "movement": "IV",
    "underneath": "hook",
    "contract": "ot-expand"
  },
  "type": "registry:ui"
}
