{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rotunda-carousel",
  "title": "Rotunda Carousel",
  "description": "Square images and videos arranged on a spinning ring, largest at a focus angle and tapering to small tiles opposite it. Auto-spins in either direction, eases to a stop on hover, enlarges the hovered tile, and spins by dragging around the centre or scrolling, with momentum. Optional links per tile, reduced-motion support, no dependencies.",
  "files": [
    {
      "path": "registry/new-york/blocks/rotunda-carousel/rotunda-carousel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport type RotundaCarouselItem = {\n  src: string\n  alt?: string\n  /** Inferred from the file extension when omitted. Videos play muted and looped. */\n  type?: \"image\" | \"video\"\n  /** Poster frame for videos. */\n  poster?: string\n  /** Turns the tile into a link. */\n  href?: string\n  target?: \"_self\" | \"_blank\"\n}\n\nexport interface RotundaCarouselProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  items: RotundaCarouselItem[]\n\n  /* Layout */\n  /** Ring radius in px (before responsive fitting), measured to tile centres. */\n  radius?: number\n  /** Tile size at the focus angle, in px. */\n  maxSize?: number\n  /** Tile size opposite the focus angle, in px. */\n  minSize?: number\n  /** How sharply tiles shrink away from the focus angle. 1 is a smooth cosine; higher keeps only the focus tile large. */\n  taper?: number\n  /** Where the largest tile sits, in degrees clockwise from 12 o'clock. */\n  focusAngle?: number\n  /** Opacity of the smallest tiles, 0 to 1. */\n  minOpacity?: number\n  /** Tile corner radius in px, at the largest size. */\n  cornerRadius?: number\n  objectFit?: \"cover\" | \"contain\"\n  /** \"upright\" keeps tiles level; \"radial\" turns them with the ring. */\n  orientation?: \"upright\" | \"radial\"\n  shadow?: boolean\n  /** Repeats `items` until the ring has at least this many tiles. */\n  minItems?: number\n\n  /* Motion */\n  autoSpin?: boolean\n  /** Auto-spin speed in degrees per second. */\n  speed?: number\n  direction?: \"clockwise\" | \"counterclockwise\"\n  /** Ease the spin to a stop while the pointer is over the ring. */\n  pauseOnHover?: boolean\n  /** Scale applied to the tile under the pointer or keyboard focus. */\n  hoverScale?: number\n  /** Momentum kept per frame after a flick, 0 to 1. Higher coasts longer. */\n  glide?: number\n  /** Lag between the pointer and the ring while dragging, 0 to 0.95. */\n  smoothing?: number\n\n  /* Interaction */\n  draggable?: boolean\n  dragSensitivity?: number\n  /** Spin the ring with the mouse wheel or trackpad while hovering it. */\n  wheel?: boolean\n  grabCursor?: boolean\n}\n\nconst DEFAULTS = {\n  radius: 240,\n  maxSize: 150,\n  minSize: 56,\n  taper: 1.4,\n  focusAngle: 180,\n  minOpacity: 0.55,\n  cornerRadius: 16,\n  objectFit: \"cover\" as \"cover\" | \"contain\",\n  orientation: \"upright\" as \"upright\" | \"radial\",\n  shadow: true,\n  minItems: 8,\n  autoSpin: true,\n  speed: 10,\n  direction: \"clockwise\" as \"clockwise\" | \"counterclockwise\",\n  pauseOnHover: true,\n  hoverScale: 1.18,\n  glide: 0.95,\n  smoothing: 0.25,\n  draggable: true,\n  dragSensitivity: 1,\n  wheel: true,\n  grabCursor: true,\n}\n\ntype Settings = typeof DEFAULTS\ntype Config = Settings & { fit: number; reduced: boolean }\ntype Mode = \"free\" | \"drag\" | \"seek\"\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))\nconst mod = (a: number, n: number) => ((a % n) + n) % n\n/** Signed shortest turn from `b` to `a`, in [-180, 180). */\nconst angleDiff = (a: number, b: number) => mod(a - b + 180, 360) - 180\nconst toRad = (deg: number) => (deg * Math.PI) / 180\n\nconst isVideo = (item: RotundaCarouselItem) =>\n  item.type ? item.type === \"video\" : /\\.(mp4|webm|mov|m4v)(\\?|#|$)/i.test(item.src)\n\n/** How close a tile at `theta` is to the focus angle, 1 at the focus and 0 opposite it. */\nconst weightAt = (theta: number, c: Settings) => {\n  const d = toRad(angleDiff(theta, c.focusAngle))\n  return Math.pow((1 + Math.cos(d)) / 2, Math.max(0.1, c.taper))\n}\n\n/** Places one tile on the ring. `hover` is its eased hover amount, 0 to 1. */\nfunction placeTile(theta: number, hover: number, c: Config): React.CSSProperties {\n  const f = c.fit\n  const w = weightAt(theta, c)\n  const size = c.minSize + (c.maxSize - c.minSize) * w\n  const scale = (size / c.maxSize) * (1 + (c.hoverScale - 1) * hover) * f\n  const x = Math.sin(toRad(theta)) * c.radius * f\n  const y = -Math.cos(toRad(theta)) * c.radius * f\n  const spin = c.orientation === \"radial\" ? theta : 0\n\n  return {\n    transform: `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) rotate(${spin.toFixed(2)}deg) scale(${scale.toFixed(4)})`,\n    opacity: Math.max(c.minOpacity + (1 - c.minOpacity) * w, hover),\n    boxShadow: c.shadow\n      ? `0 ${(18 * w + 4).toFixed(1)}px ${(40 * w + 10).toFixed(1)}px -12px rgba(0,0,0,${(0.18 + 0.27 * Math.max(w, hover)).toFixed(3)})`\n      : \"none\",\n    zIndex: Math.round(w * 1000) + (hover > 0.01 ? 2000 : 0),\n  }\n}\n\n/** Repeats the items in whole passes so neighbours never show the same item twice in a row. */\nfunction buildSlots(count: number, minItems: number) {\n  if (count === 0) return []\n  const total = count * Math.max(1, Math.ceil(minItems / count))\n  return Array.from({ length: total }, (_, i) => i % count)\n}\n\nfunction splitProps(props: Record<string, unknown>) {\n  const settings: Record<string, unknown> = { ...DEFAULTS }\n  const rest: Record<string, unknown> = {}\n  for (const [key, value] of Object.entries(props)) {\n    if (key in DEFAULTS) {\n      if (value !== undefined) settings[key] = value\n    } else rest[key] = value\n  }\n  return { settings: settings as Settings, rest: rest as React.HTMLAttributes<HTMLDivElement> }\n}\n\nexport function RotundaCarousel({\n  items,\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"Image carousel\",\n  ...props\n}: RotundaCarouselProps) {\n  const { settings: s, rest } = splitProps(props)\n\n  const slots = React.useMemo(() => buildSlots(items.length, s.minItems), [items.length, s.minItems])\n\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const itemRefs = React.useRef<(HTMLElement | null)[]>([])\n  const settingsRef = React.useRef(s)\n  const slotsRef = React.useRef(slots)\n  const state = React.useRef({\n    angle: 0,\n    vel: 0,\n    target: 0,\n    mode: \"free\" as Mode,\n    /** 1 at full auto-spin speed, eased to 0 while hovered. */\n    spin: 1,\n    hovering: false,\n    focused: false,\n    hoverSlot: -1,\n    hover: [] as number[],\n    dirty: true,\n    visible: true,\n    reduced: false,\n    fit: 1,\n  })\n  const drag = React.useRef<{\n    id: number\n    startX: number\n    startY: number\n    cx: number\n    cy: number\n    lastPhi: number\n    turned: number\n    startAngle: number\n    moved: boolean\n    samples: { t: number; angle: number }[]\n  } | null>(null)\n  const suppressClick = React.useRef(false)\n\n  React.useEffect(() => {\n    settingsRef.current = s\n    slotsRef.current = slots\n    state.current.dirty = true\n  })\n\n  const stepDeg = () => 360 / Math.max(1, slotsRef.current.length)\n\n  const seekBy = (delta: number) => {\n    const st = state.current\n    const base = st.mode === \"seek\" ? st.target : st.angle\n    st.target = base + delta\n    st.vel = 0\n    st.mode = \"seek\"\n  }\n\n  // Reduced motion: no auto-spin and a quicker settle.\n  React.useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const update = () => {\n      state.current.reduced = mq.matches\n      state.current.dirty = true\n    }\n    update()\n    mq.addEventListener(\"change\", update)\n    return () => mq.removeEventListener(\"change\", update)\n  }, [])\n\n  // Scale the whole ring down to fit small containers.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const ro = new ResizeObserver(([entry]) => {\n      const { width, height } = entry.contentRect\n      const c = settingsRef.current\n      const fit = Math.min(width, height) / (c.radius * 2 + c.maxSize * c.hoverScale + 24)\n      state.current.fit = clamp(fit, 0.3, 1)\n      state.current.dirty = true\n    })\n    ro.observe(root)\n    return () => ro.disconnect()\n  }, [])\n\n  // Skip work while off screen.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const io = new IntersectionObserver(([entry]) => {\n      state.current.visible = entry.isIntersecting\n    })\n    io.observe(root)\n    return () => io.disconnect()\n  }, [])\n\n  // Wheel and trackpad. Needs a non-passive listener to stop the page scrolling.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const onWheel = (e: WheelEvent) => {\n      const c = settingsRef.current\n      if (!c.wheel || !slotsRef.current.length) return\n      const raw = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY\n      if (!raw) return\n      e.preventDefault()\n      const delta = raw * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1)\n      const st = state.current\n      if (st.mode === \"seek\") st.mode = \"free\"\n      st.vel = clamp(st.vel + delta * 0.5 * c.dragSensitivity, -720, 720)\n    }\n    root.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => root.removeEventListener(\"wheel\", onWheel)\n  }, [])\n\n  // Animation loop. Writes styles straight to the DOM so React never re-renders per frame.\n  React.useEffect(() => {\n    let raf = 0\n    let last = performance.now()\n\n    const render = (c: Config) => {\n      const st = state.current\n      const n = slotsRef.current.length\n      for (let i = 0; i < n; i++) {\n        const el = itemRefs.current[i]\n        if (el) Object.assign(el.style, placeTile(st.angle + (i * 360) / n, st.hover[i] ?? 0, c))\n      }\n    }\n\n    const tick = (now: number) => {\n      raf = requestAnimationFrame(tick)\n      const dt = Math.min((now - last) / 1000, 0.05)\n      last = now\n      const st = state.current\n      const n = slotsRef.current.length\n      if (!st.visible || !n || dt <= 0) return\n      const c: Config = { ...settingsRef.current, fit: st.fit, reduced: st.reduced }\n      const frames = dt * 60\n      const prev = st.angle\n      let changed = st.dirty\n\n      const held = (c.pauseOnHover && st.hovering) || st.focused || st.mode === \"drag\"\n      const spinTarget = c.autoSpin && !c.reduced && !held ? 1 : 0\n      st.spin += (spinTarget - st.spin) * (1 - Math.pow(0.93, frames))\n      if (Math.abs(spinTarget - st.spin) < 0.001) st.spin = spinTarget\n\n      switch (st.mode) {\n        case \"drag\":\n          break\n        case \"seek\": {\n          const k = c.reduced ? 0.5 : 0.14\n          st.angle += (st.target - st.angle) * (1 - Math.pow(1 - k, frames))\n          if (Math.abs(st.target - st.angle) < 0.01) {\n            st.angle = st.target\n            st.mode = \"free\"\n          }\n          break\n        }\n        case \"free\": {\n          const dir = c.direction === \"counterclockwise\" ? -1 : 1\n          const cruise = dir * c.speed * st.spin\n          st.vel = cruise + (st.vel - cruise) * Math.pow(clamp(c.glide, 0, 0.995), frames)\n          if (Math.abs(st.vel - cruise) < 0.05) st.vel = cruise\n          if (st.vel !== 0) st.angle += st.vel * dt\n          break\n        }\n      }\n\n      // Ease each tile's hover amount toward 1 for the hovered tile and 0 for the rest.\n      for (let i = 0; i < n; i++) {\n        const cur = st.hover[i] ?? 0\n        const goal = i === st.hoverSlot ? 1 : 0\n        if (cur === goal) continue\n        const next = cur + (goal - cur) * (1 - Math.pow(c.reduced ? 0.4 : 0.78, frames))\n        st.hover[i] = Math.abs(goal - next) < 0.002 ? goal : next\n        changed = true\n      }\n\n      if (st.angle !== prev || changed) {\n        st.dirty = false\n        render(c)\n      }\n    }\n\n    raf = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(raf)\n  }, [])\n\n  /** Pointer angle around the ring centre, in degrees clockwise from 12 o'clock. */\n  const pointerAngle = (x: number, y: number, cx: number, cy: number) =>\n    (Math.atan2(x - cx, cy - y) * 180) / Math.PI\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!s.draggable || e.button !== 0 || !slots.length) return\n    const rect = e.currentTarget.getBoundingClientRect()\n    const cx = rect.left + rect.width / 2\n    const cy = rect.top + rect.height / 2\n    const st = state.current\n    suppressClick.current = false\n    drag.current = {\n      id: e.pointerId,\n      startX: e.clientX,\n      startY: e.clientY,\n      cx,\n      cy,\n      lastPhi: pointerAngle(e.clientX, e.clientY, cx, cy),\n      turned: 0,\n      startAngle: st.angle,\n      moved: false,\n      samples: [{ t: performance.now(), angle: st.angle }],\n    }\n  }\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current\n    if (!d || d.id !== e.pointerId) return\n    const st = state.current\n    if (!d.moved) {\n      if (Math.hypot(e.clientX - d.startX, e.clientY - d.startY) < 5) return\n      // Capture only once it is clearly a drag, so a plain tap still reaches a tile's link.\n      d.moved = true\n      e.currentTarget.setPointerCapture(e.pointerId)\n      st.mode = \"drag\"\n      st.vel = 0\n      if (s.grabCursor) e.currentTarget.style.cursor = \"grabbing\"\n    }\n\n    // Unwrapped angular travel. Ignore the jittery zone right at the centre.\n    if (Math.hypot(e.clientX - d.cx, e.clientY - d.cy) > 24) {\n      const phi = pointerAngle(e.clientX, e.clientY, d.cx, d.cy)\n      d.turned += angleDiff(phi, d.lastPhi)\n      d.lastPhi = phi\n    }\n\n    const k = clamp(s.smoothing, 0, 0.95)\n    const goal = d.startAngle + d.turned * s.dragSensitivity\n    st.angle += (goal - st.angle) * (1 - k)\n    const now = performance.now()\n    d.samples.push({ t: now, angle: goal })\n    while (d.samples.length > 2 && now - d.samples[0].t > 100) d.samples.shift()\n  }\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current\n    if (!d || d.id !== e.pointerId) return\n    drag.current = null\n    if (!d.moved) return\n    suppressClick.current = true\n    if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)\n    if (s.grabCursor) e.currentTarget.style.cursor = \"grab\"\n    const st = state.current\n    const now = performance.now()\n    const first = d.samples[0]\n    const lastSample = d.samples[d.samples.length - 1]\n    const stale = now - lastSample.t > 80\n    const vel = now > first.t ? ((lastSample.angle - first.angle) / (now - first.t)) * 1000 : 0\n    st.vel = stale ? 0 : clamp(vel, -1080, 1080)\n    st.mode = \"free\"\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") seekBy(stepDeg())\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") seekBy(-stepDeg())\n    else return\n    e.preventDefault()\n  }\n\n  const initial: Config = { ...s, fit: 1, reduced: false }\n\n  return (\n    <div\n      {...rest}\n      ref={rootRef}\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={ariaLabel}\n      tabIndex={0}\n      className={[\n        \"relative overflow-hidden select-none outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      ]\n        .filter(Boolean)\n        .join(\" \")}\n      style={{\n        touchAction: \"pan-y\",\n        cursor: s.draggable && s.grabCursor ? \"grab\" : undefined,\n        ...style,\n      }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\") state.current.hovering = true\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType !== \"mouse\") return\n        state.current.hovering = false\n        state.current.hoverSlot = -1\n      }}\n      onClickCapture={(e) => {\n        if (!suppressClick.current) return\n        suppressClick.current = false\n        e.preventDefault()\n        e.stopPropagation()\n      }}\n      onFocus={(e) => {\n        if (e.target !== e.currentTarget) state.current.focused = true\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {\n          state.current.focused = false\n          state.current.hoverSlot = -1\n        }\n      }}\n      onKeyDown={onKeyDown}\n    >\n      {slots.map((itemIndex, i) => {\n        const item = items[itemIndex]\n        const Tile = item.href ? \"a\" : \"div\"\n        const media = isVideo(item) ? (\n          <video\n            src={item.src}\n            poster={item.poster}\n            aria-label={item.alt}\n            muted\n            loop\n            autoPlay\n            playsInline\n            preload=\"metadata\"\n            className=\"pointer-events-none block size-full\"\n            style={{ objectFit: s.objectFit }}\n          />\n        ) : (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            src={item.src}\n            alt={item.alt ?? \"\"}\n            draggable={false}\n            decoding=\"async\"\n            className=\"pointer-events-none block size-full select-none\"\n            style={{ objectFit: s.objectFit }}\n          />\n        )\n        return (\n          <Tile\n            key={i}\n            ref={(el: HTMLElement | null) => {\n              itemRefs.current[i] = el\n            }}\n            {...(item.href\n              ? {\n                  href: item.href,\n                  target: item.target,\n                  rel: item.target === \"_blank\" ? \"noopener noreferrer\" : undefined,\n                  draggable: false,\n                }\n              : {})}\n            // Repeated passes are hidden from assistive tech so each item is announced once.\n            aria-hidden={i >= items.length || undefined}\n            tabIndex={item.href && i >= items.length ? -1 : undefined}\n            onPointerEnter={(e: React.PointerEvent) => {\n              if (e.pointerType === \"mouse\" && !drag.current?.moved) state.current.hoverSlot = i\n            }}\n            onPointerLeave={(e: React.PointerEvent) => {\n              if (e.pointerType === \"mouse\" && state.current.hoverSlot === i) state.current.hoverSlot = -1\n            }}\n            onFocus={() => {\n              state.current.hoverSlot = i\n            }}\n            className=\"outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            style={{\n              position: \"absolute\",\n              left: \"50%\",\n              top: \"50%\",\n              width: s.maxSize,\n              height: s.maxSize,\n              marginLeft: -s.maxSize / 2,\n              marginTop: -s.maxSize / 2,\n              borderRadius: s.cornerRadius,\n              overflow: \"hidden\",\n              willChange: \"transform, opacity\",\n              background: \"var(--muted, #111)\",\n              ...placeTile((i * 360) / slots.length, 0, initial),\n            }}\n          >\n            {media}\n          </Tile>\n        )\n      })}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}