{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel",
  "title": "Carousel",
  "description": "One poster at a time, each name set so large the frame crops it. The count between the arrows rolls the way you travel.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/carousel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { roll } from \"@/lib/0nlytype/roll\"\n\nconst Slide = React.createContext({ index: 0, total: 0, current: false, shelf: false, choose: () => {} })\n\nconst pad = (n: number) => String(n).padStart(2, \"0\")\n\ntype CarouselProps = React.ComponentProps<\"div\"> & {\n  /**\n   * poster: one slide at a time, the name sinking under a hairline.\n   * line: every name on one line of type, read along; the one at the start inks and takes the accent full stop.\n   * shelf: the slides stand as spines, their names running up them; the one you take out faces you.\n   */\n  variant?: \"poster\" | \"line\" | \"shelf\"\n  /** Names the slides for the keyboard: the track takes focus, so it needs a name. */\n  trackLabel?: string\n  previousLabel?: string\n  nextLabel?: string\n}\n\n/**\n * One slide at a time. The count between the arrows rolls the way you travel.\n * Swipe, scroll, use the arrows, or focus the track and press the arrow keys.\n * Slides are CarouselItems, as direct children.\n */\nfunction Carousel({ variant = \"poster\", trackLabel = \"Slides\", previousLabel = \"Previous\", nextLabel = \"Next\", className, children, ...props }: CarouselProps) {\n  const track = React.useRef<HTMLDivElement>(null)\n  const now = React.useRef<HTMLSpanElement>(null)\n  const items = React.Children.toArray(children)\n  const total = items.length\n  const shelf = variant === \"shelf\"\n  const [at, setAt] = React.useState(0)\n  const [shown, setShown] = React.useState(0)\n  const was = React.useRef(0)\n\n  const settle = React.useCallback((i: number) => {\n    if (i === was.current) return\n    const dir = i > was.current ? 1 : -1\n    was.current = i\n    setAt(i)\n    if (now.current) roll(now.current, () => setShown(i), \"0.6em\", dir)\n    else setShown(i)\n  }, [])\n\n  // The slide whose start edge is nearest the track's start is the one you're on, while you swipe or scroll.\n  React.useEffect(() => {\n    const t = track.current\n    if (!t || shelf) return\n    let frame = 0\n    const read = () => {\n      frame = 0\n      const side = getComputedStyle(t).direction === \"rtl\" ? \"right\" : \"left\"\n      const edge = t.getBoundingClientRect()[side]\n      let best = 0\n      let near = Infinity\n      ;[...t.children].forEach((c, i) => {\n        const d = Math.abs(c.getBoundingClientRect()[side] - edge)\n        if (d < near) [near, best] = [d, i]\n      })\n      settle(best)\n    }\n    const onScroll = () => void (frame ||= requestAnimationFrame(read))\n    t.addEventListener(\"scroll\", onScroll, { passive: true })\n    return () => {\n      t.removeEventListener(\"scroll\", onScroll)\n      cancelAnimationFrame(frame)\n    }\n  }, [total, shelf, settle])\n\n  const go = (i: number) => {\n    const to = Math.max(0, Math.min(total - 1, i))\n    // The shelf doesn't scroll: taking a spine out is the move.\n    if (shelf) return settle(to)\n    track.current?.children[to]?.scrollIntoView({ inline: \"start\", block: \"nearest\", behavior: matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" })\n  }\n\n  return (\n    <div data-slot=\"carousel\" data-variant={variant} role=\"region\" aria-roledescription=\"carousel\" className={cn(\"ot-carousel\", className)} {...props}>\n      <div\n        ref={track}\n        data-slot=\"carousel-track\"\n        tabIndex={0}\n        aria-label={trackLabel}\n        className=\"ot-carousel-track\"\n        onKeyDown={(e) => {\n          const target = e.target as HTMLElement\n          if (e.defaultPrevented || (target !== e.currentTarget && (target.isContentEditable || target.closest(\"input, textarea, select\")))) return\n          const rtl = getComputedStyle(e.currentTarget).direction === \"rtl\"\n          const to = { ArrowLeft: at + (rtl ? 1 : -1), ArrowRight: at + (rtl ? -1 : 1), Home: 0, End: total - 1 }[e.key]\n          if (to === undefined) return\n          e.preventDefault()\n          go(to)\n        }}\n      >\n        {items.map((item, index) => (\n          <Slide.Provider key={index} value={{ index, total, current: index === at, shelf, choose: () => go(index) }}>\n            {item}\n          </Slide.Provider>\n        ))}\n      </div>\n      <div data-slot=\"carousel-nav\" className=\"ot-carousel-nav\">\n        <button type=\"button\" data-slot=\"carousel-previous\" className=\"ot-carousel-arrow\" aria-label={previousLabel} disabled={at === 0} onClick={() => go(at - 1)}>\n          ←\n        </button>\n        <span data-slot=\"carousel-count\" className=\"ot-carousel-count\" aria-live=\"polite\" aria-atomic=\"true\">\n          <span ref={now} className=\"ot-carousel-now\">{pad(shown + 1)}</span> / {pad(total)}\n        </span>\n        <button type=\"button\" data-slot=\"carousel-next\" className=\"ot-carousel-arrow\" aria-label={nextLabel} disabled={at >= total - 1} onClick={() => go(at + 1)}>\n          →\n        </button>\n      </div>\n    </div>\n  )\n}\n\nfunction CarouselItem({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  const { index, total, current, shelf, choose } = React.useContext(Slide)\n  return (\n    <div\n      data-slot=\"carousel-item\"\n      data-current={current ? \"\" : undefined}\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={`${index + 1} of ${total}`}\n      className={cn(\"ot-carousel-slide\", className)}\n      {...props}\n    >\n      {children}\n      {/* A spine on the shelf is taken out with a click; the keyboard has the track's arrows and the buttons. */}\n      {shelf && !current ? <button type=\"button\" tabIndex={-1} className=\"ot-carousel-spine\" aria-label={`Show ${index + 1} of ${total}`} onClick={choose} /> : null}\n    </div>\n  )\n}\n\n/** A name set so large the frame crops it. */\nfunction CarouselTitle({ className, ...props }: React.ComponentProps<\"p\">) {\n  return <p data-slot=\"carousel-title\" className={cn(\"ot-carousel-title\", className)} {...props} />\n}\n\nexport { Carousel, CarouselItem, CarouselTitle, type CarouselProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/carousel.css",
      "content": "@layer components {\n  /* ── ot-carousel ── one poster at a time; the count rolls the way you travel.\n     The arrows are bare words, no brackets. */\n  .ot-carousel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--ot-space-5); min-width: 0; }\n  .ot-carousel-track {\n    display: grid; grid-auto-flow: column; grid-auto-columns: 100%; overflow-x: auto; overscroll-behavior-x: contain;\n    scroll-snap-type: x mandatory; scrollbar-width: none; outline-offset: var(--ot-space-3);\n  }\n  .ot-carousel-track::-webkit-scrollbar { display: none; }\n  .ot-carousel-slide { position: relative; scroll-snap-align: start; display: grid; grid-template-columns: minmax(0, 1fr); align-content: end; gap: var(--ot-space-4); min-width: 0; min-height: 19rem; overflow: clip; }\n  /* The name sinks under a hairline, its feet cut off by it, the way SPECTRA's rule crops its word. */\n  .ot-carousel-title {\n    height: 0.7em; margin-inline-start: -0.05em; overflow: clip; white-space: nowrap; border-bottom: var(--ot-hairline) solid var(--ot-rule-strong);\n    font-size: var(--ot-fff); line-height: var(--ot-fff-lh); letter-spacing: var(--ot-fff-tr); font-weight: 200; color: var(--ot-ink);\n  }\n  .ot-carousel-title:is([dir=\"rtl\"], [dir=\"rtl\"] *) { margin-inline-start: 0; }\n  .ot-carousel-nav { display: flex; align-items: baseline; gap: var(--ot-space-5); font-size: var(--ot-mp); }\n  .ot-carousel-arrow { appearance: none; border: 0; background: none; padding: 0.1em; font: inherit; color: var(--ot-ink); cursor: pointer; transition: translate var(--ot-allegro) var(--ot-exhale); }\n  .ot-carousel-arrow:first-child { margin-inline-start: -0.1em; }\n  .ot-carousel-arrow:first-child:hover:not(:disabled) { translate: -3px 0; }\n  .ot-carousel-arrow:last-child:hover:not(:disabled) { translate: 3px 0; }\n  .ot-carousel-arrow:disabled { color: var(--ot-rule-strong); cursor: default; }\n  /* An arrow has a direction: flip the glyph and its nudge. */\n  .ot-carousel-arrow:is([dir=\"rtl\"], [dir=\"rtl\"] *) { scale: -1 1; }\n  .ot-carousel-arrow:first-child:is([dir=\"rtl\"], [dir=\"rtl\"] *):hover:not(:disabled) { translate: 3px 0; }\n  .ot-carousel-arrow:last-child:is([dir=\"rtl\"], [dir=\"rtl\"] *):hover:not(:disabled) { translate: -3px 0; }\n  .ot-carousel-count { color: var(--ot-pencil); font-variant-numeric: tabular-nums; direction: ltr; unicode-bidi: isolate; }\n  .ot-carousel-now { display: inline-block; color: var(--ot-ink); }\n\n  /* Line: the names run on as one line of display type, each closed by a full stop, and you read along it.\n     The name at the start is the one you're on: it inks and its full stop takes the accent; the rest wait in pencil,\n     the next one cropped by the frame's end. A spacer after the last lets it, too, come to the start. */\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-track { grid-auto-columns: max-content; column-gap: var(--ot-space-6); container-type: inline-size; }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-track::after { content: \"\"; width: 60cqi; }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-slide { min-height: 0; align-content: start; gap: var(--ot-space-3); overflow: visible; }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-title { height: auto; overflow: visible; border: 0; padding-bottom: 0.14em; color: var(--ot-pencil); transition: color var(--ot-moderato) var(--ot-breath); }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-title::after { content: \".\" / \"\"; transition: color var(--ot-moderato) var(--ot-breath); }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-slide > :not(.ot-carousel-title) { opacity: 0; transition: opacity var(--ot-moderato) var(--ot-breath); }\n  .ot-carousel[data-variant=\"line\"] .ot-carousel-slide[data-current] > * { opacity: 1; }\n  .ot-carousel[data-variant=\"line\"] [data-current] > .ot-carousel-title { color: var(--ot-ink); }\n  .ot-carousel[data-variant=\"line\"] [data-current] > .ot-carousel-title::after { color: var(--ot-accent); }\n\n  /* Shelf: every slide stands as a spine, a hairline between each, its name running up it in pencil.\n     Take one out and it widens and turns to face you, the name sinking under its hairline; the other goes back. */\n  .ot-carousel[data-variant=\"shelf\"] { --spine: var(--ot-space-7); }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-track { display: flex; overflow: clip; scroll-snap-type: none; }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide {\n    flex: 0 0 var(--spine); padding-inline: var(--ot-space-3); border-inline-start: var(--ot-hairline) solid var(--ot-rule);\n    transition: flex-grow var(--ot-andante) var(--ot-breath);\n  }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide:first-child { border-inline-start: 0; padding-inline-start: 0; }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide[data-current] { flex-grow: 1; }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide:not([data-current]) > :not(.ot-carousel-title, .ot-carousel-spine) { display: none; }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide:not([data-current]) > .ot-carousel-title {\n    justify-self: start; height: auto; margin: 0; border: 0; writing-mode: vertical-rl; rotate: 180deg;\n    font-size: var(--ot-mp); line-height: 1; letter-spacing: var(--ot-mp-tr); font-weight: 300; color: var(--ot-pencil);\n    transition: color var(--ot-allegro) var(--ot-exhale);\n  }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide:not([data-current]):has(.ot-carousel-spine:hover) > .ot-carousel-title { color: var(--ot-ink); }\n  .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide[data-current] > * { animation: ot-carousel-face var(--ot-moderato) var(--ot-exhale) var(--ot-allegro) backwards; }\n  @keyframes ot-carousel-face { from { opacity: 0; } }\n  .ot-carousel-spine { position: absolute; inset: 0; appearance: none; border: 0; padding: 0; background: none; cursor: pointer; }\n  @media (max-width: 859px) { .ot-carousel[data-variant=\"shelf\"] { --spine: var(--ot-space-6); } }\n\n  @media (forced-colors: active) {\n    .ot-carousel[data-variant=\"line\"] .ot-carousel-title { color: GrayText; }\n    .ot-carousel[data-variant=\"line\"] [data-current] > .ot-carousel-title, .ot-carousel[data-variant=\"line\"] [data-current] > .ot-carousel-title::after { color: CanvasText; }\n    .ot-carousel[data-variant=\"shelf\"] .ot-carousel-slide { border-color: CanvasText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/carousel.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/carousel.css\"": {}
  },
  "meta": {
    "movement": "IX",
    "underneath": "hook",
    "contract": "ot-carousel"
  },
  "type": "registry:ui"
}
