{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "curve-carousel",
  "title": "Curve carousel",
  "description": "Cards on a curve in ten layouts, from a fan and a coverflow to a helix, a rolodex and a row that bulges when flung; drag momentum, keys and trackpad.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/curve-carousel/curve-carousel.tsx",
      "content": "import { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'\nimport { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'\nimport { cn } from '@/lib/utils'\nimport { curveLayouts, type CurveContext, type CurveLayout, type CurveLayoutName, type CurveSize } from './layouts'\n\nexport type CurveCarouselProps = Omit<HTMLAttributes<HTMLElement>, 'onChange'> & {\n  /** One card per slide. An img or an SVG fills its card edge to edge. In the shingle layout each card sets\n   *  `--curve-shift` (px); content that translates by it pans inside its card, e.g. `[translate:var(--curve-shift,0px)_0]`\n   *  on an img a little wider than the card. */\n  slides: ReactNode[]\n  /** Read by screen readers, e.g. “Recent work”. */\n  label: string\n  /** The curve the cards sit on: one of the built-in layouts, or your own. */\n  layout?: CurveLayoutName | CurveLayout\n  /** Wrap round from the last card to the first. Ring layouts wrap by default. */\n  loop?: boolean\n  /** Controlled: the card in focus. */\n  index?: number\n  /** The card in focus at first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the carousel heads for a new card: on release, a key, a button or a click. */\n  onIndexChange?: (index: number) => void\n  /** Card width over height. Each layout has its own default. */\n  aspect?: number\n  /** Show the previous and next buttons and the position rule. */\n  controls?: boolean\n  labels?: Partial<Record<'previous' | 'next' | 'slide' | 'of' | 'slides', string>>\n}\n\nconst EN = { previous: 'Previous', next: 'Next', slide: 'Slide', of: 'of', slides: 'Slides' }\nconst TYPING = 'input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]'\n\n/**\n * Cards on a curve: a fan, an arc, a coverflow, a drum, a wall that wraps round you, a helix, a double helix,\n * a rolodex, overlapping tiles, or a row that bulges towards you when it moves fast. Drag or swipe with momentum, use a trackpad, the arrow keys, the buttons, or\n * click a card to bring it to the front. Every frame is written as transform, opacity and filter only, and\n * cards are painted in depth order. Under reduced motion each move jumps straight to the new card.\n */\nexport function CurveCarousel({\n  slides,\n  label,\n  layout = 'coverflow',\n  loop,\n  index,\n  defaultIndex,\n  onIndexChange,\n  aspect,\n  controls = true,\n  labels = {},\n  className,\n  onKeyDown,\n  ...rest\n}: CurveCarouselProps) {\n  const t = { ...EN, ...labels }\n  const L = typeof layout === 'string' ? curveLayouts[layout] : layout\n  const n = slides.length\n  const wraps = (loop ?? L.loop) && n > 2\n  const id = useId()\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const cards = useRef<(HTMLDivElement | null)[]>([])\n  const ticks = useRef<(HTMLSpanElement | null)[]>([])\n  const [width, setWidth] = useState(0)\n  const ratio = aspect ?? L.aspect\n  const size: CurveSize | null = width ? L.size(width, ratio, n) : null\n  const ctx = useRef<CurveContext | null>(null)\n\n  const paint = useCallback(\n    (p: number, velocity: number) => {\n      const c = ctx.current\n      if (!c) return\n      c.velocity = velocity\n      const range = L.range(c)\n      for (let i = 0; i < n; i++) {\n        const el = cards.current[i]\n        if (!el) continue\n        let d = i - p\n        if (wraps) d = ((((d + n / 2) % n) + n) % n) - n / 2\n        const pose = Math.abs(d) <= range ? L.pose(d, c) : null\n        // Near the seam of a short loop a card is about to jump to the other end, so it fades out first.\n        const seam = wraps ? Math.min(1, Math.max(0, (n / 2 - Math.abs(d)) / 0.75)) : 1\n        const opacity = pose ? (pose.opacity ?? 1) * seam : 0\n        if (!pose || opacity < 0.004) {\n          el.style.visibility = 'hidden'\n          continue\n        }\n        const { x = 0, y = 0, z = 0, rotateX = 0, rotateY = 0, rotateZ = 0, scale = 1, blur = 0, fog = 0, shift = 0 } = pose\n        el.style.visibility = 'visible'\n        el.style.transform =\n          `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, ${z.toFixed(2)}px)` +\n          (rotateX ? ` rotateX(${rotateX.toFixed(3)}deg)` : '') +\n          (rotateY ? ` rotateY(${rotateY.toFixed(3)}deg)` : '') +\n          (rotateZ ? ` rotateZ(${rotateZ.toFixed(3)}deg)` : '') +\n          (scale !== 1 ? ` scale(${scale.toFixed(4)})` : '')\n        el.style.opacity = opacity.toFixed(3)\n        el.style.filter = blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : ''\n        el.style.zIndex = String(Math.round(pose.order ?? z) + 10000)\n        const fogEl = el.lastElementChild as HTMLElement\n        fogEl.style.opacity = fog > 0.002 ? Math.min(fog, 1).toFixed(3) : '0'\n        if (L.shift) el.style.setProperty('--curve-shift', `${(shift * L.shift * c.cardWidth).toFixed(2)}px`)\n      }\n      // The rule under the stage follows the position, also mid-drag.\n      const at = wraps ? (((p % n) + n) % n) / n : (Math.min(Math.max(p, 0), n - 1) / Math.max(n - 1, 1)) * (1 - 1 / n)\n      ticks.current.forEach((el, k) => {\n        if (el) el.style.transform = `translateX(${((at - k) * 100).toFixed(3)}cqw)`\n      })\n    },\n    [L, n, wraps],\n  )\n\n  const engine = useCarouselEngine({\n    count: n,\n    loop: wraps,\n    index,\n    defaultIndex,\n    onIndexChange,\n    axis: L.axis,\n    step: size ? L.step({ ...size, width, count: n, velocity: 0 }) : 300,\n    onFrame: paint,\n  })\n  const current = engine.index\n\n  // The stage only exists while there are cards, so watch it again when the first ones arrive.\n  const empty = !n\n  useLayoutEffect(() => {\n    const el = stage.current\n    if (!el) return\n    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [empty])\n\n  // A new size, layout or set of cards: draw the current frame again.\n  const { draw } = engine\n  useLayoutEffect(() => {\n    ctx.current = width ? { ...L.size(width, ratio, n), width, count: n, velocity: 0 } : null\n    draw()\n  }, [draw, paint, L, width, ratio, n])\n\n  // If focus sat in a card that just left the front, keep it in the carousel.\n  useEffect(() => {\n    const el = stage.current\n    const active = document.activeElement\n    if (!el || !active || active === el || !el.contains(active)) return\n    if (!cards.current[current]?.contains(active)) el.focus({ preventScroll: true })\n  }, [current])\n\n  const keys = (e: KeyboardEvent<HTMLElement>) => {\n    onKeyDown?.(e)\n    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return\n    const back = L.axis === 'x' ? 'ArrowLeft' : 'ArrowUp'\n    const fwd = L.axis === 'x' ? 'ArrowRight' : 'ArrowDown'\n    if (e.key === back || e.key === fwd) engine.move(e.key === fwd ? 1 : -1)\n    else if (e.key === 'Home') engine.go(0)\n    else if (e.key === 'End') engine.go(n - 1)\n    else return\n    e.preventDefault()\n  }\n\n  if (!n) return null\n\n  const atStart = !wraps && current === 0\n  const atEnd = !wraps && current === n - 1\n  const button =\n    'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-foreground [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--muted))] ' +\n    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:active:scale-100 motion-reduce:transition-none ' +\n    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'\n\n  return (\n    <section aria-roledescription=\"carousel\" aria-label={label} onKeyDown={keys} className={cn('w-full', className)} {...rest}>\n      <div\n        {...engine.bind}\n        ref={(el) => {\n          stage.current = el\n          engine.bind.ref(el)\n        }}\n        id={id}\n        role=\"group\"\n        aria-label={t.slides}\n        tabIndex={0}\n        style={{ height: size?.height ?? 320, perspective: size?.perspective, perspectiveOrigin: size?.origin }}\n        className={cn(\n          'relative w-full overflow-x-clip rounded-[calc(var(--radius)*2+2px)] select-none [-webkit-tap-highlight-color:transparent]',\n          L.axis === 'x' ? 'touch-pan-y' : 'touch-pan-x',\n          'cursor-grab data-dragging:cursor-grabbing',\n          'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring',\n        )}\n      >\n        {size &&\n          slides.map((slide, i) => {\n            const on = i === current\n            return (\n              <div\n                key={i}\n                ref={(el) => {\n                  cards.current[i] = el\n                }}\n                role=\"group\"\n                aria-roledescription=\"slide\"\n                aria-label={`${t.slide} ${i + 1} ${t.of} ${n}`}\n                aria-hidden={!on}\n                onClick={on ? undefined : () => engine.go(i)}\n                style={{ width: size.cardWidth, height: size.cardHeight, marginLeft: -size.cardWidth / 2, marginTop: -size.cardHeight / 2 }}\n                className={cn(\n                  'absolute top-1/2 left-1/2 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-card will-change-transform',\n                  'shadow-[0_1px_2px_rgb(0_0_0/0.06),0_18px_36px_-18px_rgb(0_0_0/0.45)]',\n                  !on && 'cursor-pointer',\n                  // Hidden until the first frame places it, except the card in focus.\n                  !on && 'invisible',\n                )}\n              >\n                <div\n                  inert={!on}\n                  className=\"absolute inset-0 [&>img]:size-full [&>img]:object-cover [&>img]:[-webkit-user-drag:none] [&>svg]:size-full\"\n                >\n                  {slide}\n                </div>\n                <span aria-hidden className=\"pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]\" />\n                <span aria-hidden className=\"pointer-events-none absolute inset-0 bg-background opacity-0\" />\n              </div>\n            )\n          })}\n      </div>\n\n      {controls && n > 1 ? (\n        <div className=\"mx-auto mt-4 flex max-w-sm items-center gap-3\">\n          {/* aria-disabled, not disabled: a button that has focus keeps it when it reaches the end. */}\n          <button type=\"button\" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={button}>\n            <ChevronLeft className=\"size-[18px]\" strokeWidth={2} aria-hidden />\n          </button>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n            {/* A hairline rule with a primary tick, one card's share of it long, that follows the position. In a\n                loop a second tick trails one rule-length behind, so the tick slides off one end and on at the other. */}\n            <div aria-hidden className=\"@container relative h-1 overflow-hidden rounded-full bg-muted\">\n              {(wraps ? [0, 1] : [0]).map((k) => (\n                <span\n                  key={k}\n                  ref={(el) => {\n                    ticks.current[k] = el\n                  }}\n                  style={{ width: `${100 / n}%` }}\n                  className=\"absolute inset-y-0 left-0 block rounded-full bg-primary\"\n                />\n              ))}\n            </div>\n            <p className=\"flex justify-between font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums\">\n              <span aria-hidden>{String(current + 1).padStart(2, '0')}</span>\n              <span aria-hidden>{String(n).padStart(2, '0')}</span>\n            </p>\n          </div>\n          <button type=\"button\" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={button}>\n            <ChevronRight className=\"size-[18px]\" strokeWidth={2} aria-hidden />\n          </button>\n        </div>\n      ) : null}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`${t.slide} ${current + 1} ${t.of} ${n}`}\n      </p>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/curve-carousel/layouts.ts",
      "content": "// The curves the cards of a CurveCarousel sit on. Each layout is a pure function from a card's distance to the\n// focus (in slides, fractional while moving) to a pose, so a new curve is a few lines of maths.\n\n/** Where one card sits. Lengths in px from the stage's centre, angles in degrees. */\nexport type CurvePose = {\n  x?: number\n  y?: number\n  /** Towards the viewer. */\n  z?: number\n  rotateX?: number\n  rotateY?: number\n  rotateZ?: number\n  scale?: number\n  opacity?: number\n  /** Blur in px. */\n  blur?: number\n  /** 0–1: how far the card fades into the page's background. */\n  fog?: number\n  /** Paint order. Defaults to depth (`z`). */\n  order?: number\n  /** -1 to 1: how far the card's picture slides inside its frame. The card sets `--curve-shift` to this\n   *  times the layout's `shift` times its width; content opts in by translating by it. */\n  shift?: number\n}\n\nexport type CurveSize = {\n  cardWidth: number\n  cardHeight: number\n  /** The stage's height. */\n  height: number\n  /** The CSS perspective of the stage, in px. */\n  perspective: number\n  /** A radius or other length the layout wants to keep for its poses. */\n  radius?: number\n  /** Where the viewer looks from, as CSS `perspective-origin`. */\n  origin?: string\n}\n\nexport type CurveContext = CurveSize & {\n  /** The stage's width. */\n  width: number\n  /** How many slides there are. */\n  count: number\n  /** The carousel's speed, in slides per second. */\n  velocity: number\n}\n\nexport type CurveLayout = {\n  /** The drag direction. */\n  axis: 'x' | 'y'\n  /** Wrap around by default. */\n  loop: boolean\n  /** Card width over height, unless the carousel sets one. */\n  aspect: number\n  /** Card size and stage size for a stage this wide. */\n  size: (width: number, aspect: number, count: number) => CurveSize\n  /** Pixels of drag per slide. Negative turns the drag round. */\n  step: (c: CurveContext) => number\n  /** Cards further than this from the focus are not drawn. */\n  range: (c: CurveContext) => number\n  /** The furthest a picture slides inside its card, as a share of the card's width (see `CurvePose.shift`). */\n  shift?: number\n  pose: (d: number, c: CurveContext) => CurvePose\n}\n\nconst RAD = Math.PI / 180\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n// 0 at `a`, 1 at `b`, smooth in between.\nconst ramp = (v: number, a: number, b: number) => {\n  const t = clamp((v - a) / (b - a), 0, 1)\n  return t * t * (3 - 2 * t)\n}\n// A ring with at least `min` places, so few cards still leave the back of it empty.\nconst places = (count: number, min: number) => Math.max(count, min)\n\n/** A hand of cards held at a hinge just below them, the focused card lifted out of the hand. */\nconst fan: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 0.7,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.3, 120, 196)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * 1.52, perspective: 1600, radius: cardHeight * 1.1 }\n  },\n  step: (c) => c.cardWidth * 0.42,\n  range: () => 4.6,\n  pose: (d, c) => {\n    const r = c.radius!\n    // 7° a card, and 6° more either side of the focus, so the lifted card stands clear of the hand.\n    const near = clamp(d, -1, 1)\n    const deg = d * 7 + near * 6\n    const a = deg * RAD\n    const lift = (1 - Math.abs(near)) * c.cardHeight * 0.1\n    return {\n      x: r * Math.sin(a),\n      y: r * (1 - Math.cos(a)) - lift - c.cardHeight * 0.1,\n      rotateZ: deg,\n      fog: Math.min(Math.abs(d), 4) * 0.05,\n      opacity: ramp(4.6 - Math.abs(d), 0, 1),\n      order: Math.round(-Math.abs(d) * 100),\n    }\n  },\n}\n\n/** Cards spaced along a wide circle, the focus at its top. */\nconst arc: CurveLayout = {\n  axis: 'x',\n  loop: true,\n  aspect: 0.75,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.3, 120, 196)\n    const cardHeight = cardWidth / a\n    const radius = Math.max(w * 1.15, cardWidth * 3.6)\n    return { cardWidth, cardHeight, height: cardHeight * 1.5, perspective: 1600, radius }\n  },\n  step: (c) => c.cardWidth * 1.12,\n  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 1.12)) + 1.5,\n  pose: (d, c) => {\n    const r = c.radius!\n    const a = (d * c.cardWidth * 1.12) / r\n    const x = r * Math.sin(a)\n    return {\n      x,\n      y: r * (1 - Math.cos(a)) - c.cardHeight * 0.12,\n      rotateZ: a / RAD,\n      opacity: ramp(c.width / 2 + c.cardWidth * 0.35 - Math.abs(x), 0, c.cardWidth * 0.7),\n      order: Math.round(-Math.abs(d) * 100),\n    }\n  },\n}\n\n/** The focused card faces you; the rest turn away and stack to either side. */\nconst coverflow: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 0.8,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.4, 150, 248)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * 1.24, perspective: 1100 }\n  },\n  step: (c) => c.cardWidth * 0.55,\n  range: (c) => Math.min(6, Math.ceil((c.width / 2 - c.cardWidth * 0.6) / (c.cardWidth * 0.22)) + 2),\n  pose: (d, c) => {\n    const a = clamp(d, -1, 1)\n    const rest = d - a\n    const w = c.cardWidth\n    const out = Math.abs(rest)\n    return {\n      x: a * w * 0.62 + rest * w * 0.22,\n      z: -Math.abs(a) * w * 0.55 - out * w * 0.08,\n      rotateY: -a * 58,\n      fog: Math.abs(a) * 0.12 + Math.min(out, 3) * 0.08,\n      opacity: ramp(c.width / 2 + w * 0.1 - Math.abs(a * w * 0.62 + rest * w * 0.22), 0, w * 0.4),\n      order: Math.round(-Math.abs(d) * 100),\n    }\n  },\n}\n\n/** A drum seen from outside: the cards ride round it and pass behind. */\nconst cylinder: CurveLayout = {\n  axis: 'x',\n  loop: true,\n  aspect: 0.72,\n  size: (w, a, count) => {\n    const cardWidth = clamp(w * 0.26, 108, 172)\n    const cardHeight = cardWidth / a\n    const step = (2 * Math.PI) / places(count, 9)\n    const radius = (cardWidth * 0.56) / Math.tan(step / 2)\n    return { cardWidth, cardHeight, height: cardHeight * 1.3, perspective: radius * 2.2, radius }\n  },\n  step: (c) => (c.radius! * 2 * Math.PI) / places(c.count, 9),\n  range: (c) => places(c.count, 9) / 2,\n  pose: (d, c) => {\n    const a = (d * 2 * Math.PI) / places(c.count, 9)\n    const cos = Math.cos(a)\n    return {\n      x: c.radius! * Math.sin(a),\n      z: c.radius! * (cos - 1),\n      rotateY: a / RAD,\n      opacity: ramp(cos, -0.1, 0.4),\n      fog: (1 - cos) * 0.4,\n    }\n  },\n}\n\n/** A curved wall that wraps round the viewer, like standing inside a panorama: the nearer cards loom larger. */\nconst curl: CurveLayout = {\n  axis: 'x',\n  loop: true,\n  aspect: 0.78,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.24, 100, 168)\n    const cardHeight = cardWidth / a\n    const radius = Math.max(w * 0.5, cardWidth * 2.2)\n    return { cardWidth, cardHeight, height: cardHeight * 1.7, perspective: radius * 1.12, radius }\n  },\n  step: (c) => c.cardWidth * 1.06,\n  range: (c) => Math.ceil((64 * RAD * c.radius!) / (c.cardWidth * 1.06)) + 0.5,\n  pose: (d, c) => {\n    const r = c.radius!\n    const a = (d * c.cardWidth * 1.06) / r\n    return {\n      x: r * Math.sin(a),\n      z: r * (1 - Math.cos(a)),\n      rotateY: -a / RAD,\n      opacity: ramp(66 - Math.abs(a / RAD), 0, 14),\n      order: Math.round(-Math.abs(d) * 100),\n    }\n  },\n}\n\n/** A spiral staircase of cards: turning it walks them round the back and up or down past the front. */\nconst helix: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 1.3,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.22, 100, 150)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * (1 + 2 * 5 * 0.24), perspective: 1100, radius: cardWidth * 1.5, origin: '50% 30%' }\n  },\n  step: (c) => c.cardWidth * 0.6,\n  range: () => 5.6,\n  pose: (d, c) => {\n    const a = d * 30 * RAD\n    const cos = Math.cos(a)\n    return {\n      x: c.radius! * Math.sin(a),\n      y: d * c.cardHeight * 0.24,\n      z: c.radius! * (cos - 1),\n      rotateY: a / RAD,\n      fog: (1 - cos) * 0.3,\n      blur: (1 - cos) * 0.7,\n      opacity: ramp(5.6 - Math.abs(d), 0, 1),\n    }\n  },\n}\n\n/**\n * Two strands twisting round a horizontal axis. Each step turns the twist by a little less than half a round, so\n * neighbouring cards sit on opposite strands and each strand winds slowly along. The cards face you and the back\n * strand blurs with depth.\n */\nconst doubleHelix: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 1.3,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.19, 84, 124)\n    const cardHeight = cardWidth / a\n    const radius = cardHeight * 0.72\n    return { cardWidth, cardHeight, height: cardHeight * 1.25 + radius * 2, perspective: 800, radius }\n  },\n  step: (c) => c.cardWidth * 0.62,\n  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 0.62)) + 1,\n  pose: (d, c) => {\n    const a = d * 165 * RAD\n    const cos = Math.cos(a)\n    const x = d * c.cardWidth * 0.62\n    return {\n      x,\n      y: -c.radius! * Math.sin(a),\n      z: c.radius! * (cos - 1),\n      fog: (1 - cos) * 0.24,\n      blur: (1 - cos) * 1.6,\n      opacity: ramp(c.width / 2 + c.cardWidth * 0.2 - Math.abs(x), 0, c.cardWidth * 0.5),\n    }\n  },\n}\n\n/**\n * A card file: the cards still to come stand behind the front one, each leaning back a little more and showing\n * its top; the ones already read tip forward and drop away. Pull down for the next card.\n */\nconst rolodex: CurveLayout = {\n  axis: 'y',\n  loop: false,\n  aspect: 1.62,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.56, 220, 320)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * 1.9, perspective: 1100, origin: '50% 10%' }\n  },\n  step: (c) => -c.cardHeight * 0.7,\n  range: () => 5.5,\n  pose: (d, c) => {\n    const h = c.cardHeight\n    if (d < 0) {\n      // Tips forward on its bottom edge, towards you and down, and fades.\n      const t = Math.min(-d, 1)\n      return {\n        y: h * 0.3 + t * h * 0.42,\n        z: t * h * 0.5,\n        rotateX: -t * 70,\n        opacity: ramp(1 - t, 0, 0.7),\n        order: Math.round(-d * 100) + 1000,\n      }\n    }\n    const back = d\n    return {\n      y: h * 0.3 - back * h * 0.15,\n      z: -back * h * 0.2,\n      rotateX: Math.min(back, 1) * 10 + back * 3,\n      fog: Math.min(back, 5) * 0.11,\n      opacity: ramp(5.5 - back, 0, 1),\n      order: Math.round(-back * 100),\n    }\n  },\n}\n\n/**\n * Overlapping cards like roof tiles: the card in focus lies on top, whole; the others tuck under it, each showing\n * a slice. Pictures that use `--curve-shift` slide inside their cards as they move, so each slice is a crop.\n */\nconst shingle: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 0.78,\n  shift: 0.14,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.44, 160, 260)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * 1.08, perspective: 1600 }\n  },\n  step: (c) => c.cardWidth * 0.5,\n  range: (c) => Math.min(6, Math.ceil((c.width / 2 - c.cardWidth * 0.55) / (c.cardWidth * 0.2)) + 2),\n  pose: (d, c) => {\n    const a = clamp(d, -1, 1)\n    const rest = d - a\n    const out = Math.abs(rest)\n    const x = a * c.cardWidth * 0.56 + rest * c.cardWidth * 0.2\n    return {\n      x,\n      scale: 1 - Math.abs(a) * 0.14 - Math.min(out, 4) * 0.05,\n      fog: Math.abs(a) * 0.22 + Math.min(out, 3) * 0.1,\n      shift: -a,\n      opacity: ramp(c.width / 2 + c.cardWidth * 0.2 - Math.abs(x), 0, c.cardWidth * 0.4),\n      order: Math.round(-Math.abs(d) * 100),\n    }\n  },\n}\n\n/**\n * A flat row that bulges towards you while it moves fast: the middle swells forward and the ends bend away, more\n * the faster it goes, and the row flattens again as it slows down.\n */\nconst bulge: CurveLayout = {\n  axis: 'x',\n  loop: false,\n  aspect: 0.75,\n  size: (w, a) => {\n    const cardWidth = clamp(w * 0.3, 124, 220)\n    const cardHeight = cardWidth / a\n    return { cardWidth, cardHeight, height: cardHeight * 1.3, perspective: 1100 }\n  },\n  step: (c) => c.cardWidth * 1.08,\n  range: (c) => Math.ceil(c.width / 2 / (c.cardWidth * 1.08)) + 1,\n  pose: (d, c) => {\n    const x = d * c.cardWidth * 1.08\n    const r = c.width / 2 + c.cardWidth * 0.5\n    // 0 at rest, 1 from about nine slides a second.\n    const b = ramp(Math.abs(c.velocity), 1.2, 9) * c.cardWidth * 0.7\n    const u = x / r\n    return {\n      x,\n      z: b * (1 - u * u),\n      rotateY: Math.atan((2 * b * x) / (r * r)) / RAD,\n      opacity: ramp(c.width / 2 + c.cardWidth * 0.55 - Math.abs(x), 0, c.cardWidth * 0.3),\n    }\n  },\n}\n\nexport const curveLayouts = { fan, arc, coverflow, cylinder, curl, helix, 'double-helix': doubleHelix, rolodex, shingle, bulge } satisfies Record<\n  string,\n  CurveLayout\n>\n\nexport type CurveLayoutName = keyof typeof curveLayouts\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-carousel-engine.ts",
      "content": "import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\nexport type CarouselEngineOptions = {\n  /** How many slides there are. */\n  count: number\n  /** Wrap around from the last slide to the first. */\n  loop?: boolean\n  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */\n  index?: number\n  /** The slide shown first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */\n  onIndexChange?: (index: number) => void\n  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */\n  axis?: 'x' | 'y'\n  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */\n  step: number\n  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */\n  snap?: boolean\n  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */\n  onFrame: (position: number, velocity: number) => void\n}\n\nexport type CarouselEngine = {\n  /** The slide the carousel is on or heading to. */\n  index: number\n  /** Go to a slide. In a loop it takes the short way round. */\n  go: (index: number) => void\n  /** Move by a number of slides from where the carousel is heading. */\n  move: (by: number) => void\n  /** Draw the current frame again, e.g. after a resize. */\n  draw: () => void\n  /** Spread on the element that takes the drag. */\n  bind: {\n    ref: (el: HTMLElement | null) => void\n    onPointerDown: (e: PointerEvent<HTMLElement>) => void\n    onPointerMove: (e: PointerEvent<HTMLElement>) => void\n    onPointerUp: (e: PointerEvent<HTMLElement>) => void\n    onPointerCancel: (e: PointerEvent<HTMLElement>) => void\n    onClickCapture: (e: MouseEvent<HTMLElement>) => void\n  }\n}\n\ntype Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &\n  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }\n\nconst SLOP = 6 // px before a press becomes a drag\nconst THROW = 0.32 // s: how far a fling carries, as speed × this\nconst SAMPLE = 90 // ms of pointer history used for the release speed\nconst mod = (v: number, n: number) => ((v % n) + n) % n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n// Pixels per slide, kept away from zero but with its sign.\nconst per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)\n// Past an end the carousel gives less and less, and never more than one slide.\nconst rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)\n\n/**\n * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.\n * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms\n * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a\n * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.\n */\nexport function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {\n  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options\n  const n = Math.max(count, 1)\n  const reduce = useReducedMotion()\n  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))\n  const [m] = useState(() => machine(current, setCurrent))\n  const [surface, setSurface] = useState<HTMLElement | null>(null)\n\n  // The latest options, for the handlers and the frame loop, which outlive a render.\n  useLayoutEffect(() => {\n    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })\n  })\n\n  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.\n  useEffect(() => m.reseat(), [n, loop, m])\n  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls\n  // onIndexChange, as the parent already knows.\n  const controlled = index !== undefined\n  const target = clamp(index ?? 0, 0, n - 1)\n  useEffect(() => {\n    if (controlled && target !== m.committed) m.go(target, false)\n  })\n\n  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])\n  useEffect(() => m.stop, [m])\n\n  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }\n}\n\ntype Tween = { from: number; to: number; start: number; dur: number }\ntype Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }\n\n// The state and behaviour, made once per carousel and kept for its lifetime.\nfunction machine(start: number, setCurrent: (i: number) => void) {\n  let want = start // where a drag or the wheel puts the carousel\n  let drawn = start // the position on screen\n  let velocity = 0\n  let tween: Tween | null = null\n  let drag: Drag | null = null\n  let raf = 0\n  let last = 0\n  let swallowClick = false\n  let wheelTimer = 0\n\n  const m = {\n    set: null as Settings | null,\n    committed: start,\n    configure: (settings: Settings) => {\n      m.set = settings\n    },\n    go,\n    move,\n    reseat,\n    draw,\n    stop,\n    listenWheel,\n    handlers: {\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),\n      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),\n      onClickCapture,\n    },\n  }\n\n  function draw() {\n    m.set?.onFrame(drawn, velocity)\n  }\n\n  function tick(now: number) {\n    raf = 0\n    const o = m.set\n    if (!o) return\n    let p = want\n    if (tween) {\n      const u = clamp((now - tween.start) / tween.dur, 0, 1)\n      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)\n      if (u >= 1) tween = null\n    }\n    const dt = Math.max(now - last, 1) / 1000\n    last = now\n    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.\n    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))\n    drawn = p\n    const moving = tween || drag?.active || Math.abs(velocity) > 0.01\n    if (!moving) velocity = 0\n    o.onFrame(p, velocity)\n    if (moving) raf = requestAnimationFrame(tick)\n  }\n\n  function run() {\n    if (raf) return\n    last = performance.now()\n    raf = requestAnimationFrame(tick)\n  }\n\n  function commit(to: number, notify = true) {\n    const o = m.set\n    if (!o) return\n    const i = mod(Math.round(to), o.n)\n    if (i === m.committed) return\n    m.committed = i\n    setCurrent(i)\n    if (notify) o.onIndexChange?.(i)\n  }\n\n  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.\n  function glide(to: number, speed = 0) {\n    want = to\n    const dist = to - drawn\n    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null\n    else {\n      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300\n      tween = { from: drawn, to, start: performance.now(), dur }\n    }\n    run()\n  }\n\n  function go(i: number, notify = true) {\n    const o = m.set\n    if (!o || mod(i, o.n) === m.committed) return\n    let to: number\n    if (o.loop) {\n      const at = Math.round(tween?.to ?? want)\n      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2\n      // An even count puts the far slide exactly half way round: go forwards.\n      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2\n    } else to = clamp(i, 0, o.n - 1)\n    glide(to)\n    commit(to, notify)\n  }\n\n  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position\n  // wound past the ends of a loop means nothing in the new count, so it is unwound too.\n  function reseat() {\n    const o = m.set\n    if (!o) return\n    const i = clamp(m.committed, 0, o.n - 1)\n    if (want === i && drawn === i && !tween) return\n    tween = null\n    velocity = 0\n    want = drawn = i\n    commit(i)\n    draw()\n  }\n\n  function move(by: number) {\n    const o = m.set\n    if (!o) return\n    let to = Math.round(tween?.to ?? want) + by\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to)\n    commit(to)\n  }\n\n  function stop() {\n    cancelAnimationFrame(raf)\n    clearTimeout(wheelTimer)\n    raf = 0\n    tween = null\n    drag = null\n    velocity = 0\n    // Land where it was heading, so a remount starts settled.\n    drawn = want\n  }\n\n  // Where a position lands once the ends of a carousel that does not loop push back.\n  function give(raw: number) {\n    const o = m.set!\n    if (o.loop) return raw\n    if (raw < 0) return -rubber(-raw)\n    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))\n    return raw\n  }\n\n  function settle(speed: number, from: number) {\n    const o = m.set!\n    let to = drawn + speed * THROW\n    if (o.snap) {\n      to = Math.round(to)\n      // A quick flick always moves at least one slide.\n      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)\n    }\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to, speed)\n    commit(to)\n  }\n\n  function onPointerDown(e: PointerEvent<HTMLElement>) {\n    swallowClick = false\n    // A press that left the carousel before it became a drag never got its pointerup.\n    if (drag && !drag.active) drop(drag)\n    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return\n    const resume = tween ? tween.to : null\n    // Catch a moving carousel where it is.\n    if (tween) {\n      tween = null\n      want = drawn\n    }\n    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }\n  }\n\n  function onPointerMove(e: PointerEvent<HTMLElement>) {\n    const d = drag\n    const o = m.set\n    if (!d || !o || e.pointerId !== d.id) return\n    // The mouse came back with its button up, so it was let go outside the carousel.\n    if (!d.active && !(e.buttons & 1)) return drop(d)\n    const dx = e.clientX - d.x\n    const dy = e.clientY - d.y\n    const along = o.axis === 'x' ? dx : dy\n    const across = o.axis === 'x' ? dy : dx\n    if (!d.active) {\n      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return\n      // Moving across the carousel: leave it to the page.\n      if (Math.abs(across) >= Math.abs(along)) return drop(d)\n      d.active = true\n      // Count from here, so the slop does not make the slides jump.\n      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP\n      else d.y += Math.sign(dy) * SLOP\n      e.currentTarget.setPointerCapture(e.pointerId)\n      e.currentTarget.dataset.dragging = ''\n    }\n    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))\n    want = p\n    d.samples.push([e.timeStamp, p])\n    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()\n    run()\n  }\n\n  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {\n    const d = drag\n    if (!d || e.pointerId !== d.id) return\n    delete e.currentTarget.dataset.dragging\n    if (!d.active) return drop(d)\n    drag = null\n    swallowClick = true\n    let speed = 0\n    const [t0, p0] = d.samples[0]\n    const [t1, p1] = d.samples[d.samples.length - 1]\n    // A finger that stopped before letting go throws nothing.\n    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000\n    settle(speed, d.p0)\n  }\n\n  // Let go of a press that never became a drag, and finish any glide it caught.\n  function drop(d: Drag) {\n    drag = null\n    if (d.resume !== null) glide(d.resume)\n  }\n\n  // A drag ends with a click on whatever was under the pointer; that click is not meant.\n  function onClickCapture(e: MouseEvent<HTMLElement>) {\n    if (!swallowClick) return\n    swallowClick = false\n    e.preventDefault()\n    e.stopPropagation()\n  }\n\n  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.\n  function listenWheel(el: HTMLElement) {\n    let base: number | null = null\n    const onWheel = (e: WheelEvent) => {\n      const o = m.set\n      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)\n      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return\n      e.preventDefault()\n      if (drag?.active) return\n      tween = null\n      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)\n      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)\n      want = give(base)\n      run()\n      clearTimeout(wheelTimer)\n      wheelTimer = window.setTimeout(() => {\n        base = null\n        settle(0, drawn)\n      }, 140)\n    }\n    el.addEventListener('wheel', onWheel, { passive: false })\n    return () => {\n      el.removeEventListener('wheel', onWheel)\n      clearTimeout(wheelTimer)\n    }\n  }\n\n  return m\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}