{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dna-carousel",
  "title": "DNA Carousel",
  "description": "A cinematic 3D image carousel where cards travel along two intertwined helix strands. Drag, swipe, scroll or use the arrow keys, with momentum, snapping, infinite looping, optional autoplay, blur and RGB-split depth effects, and reduced-motion support. No dependencies.",
  "files": [
    {
      "path": "registry/new-york/blocks/dna-carousel/dna-carousel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport type DnaCarouselImage = { src: string; alt?: string }\n\nexport interface DnaCarouselProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  images: DnaCarouselImage[]\n\n  /* Layout */\n  /** Card width in px (before responsive fitting). */\n  itemWidth?: number\n  /** Card height in px (before responsive fitting). */\n  itemHeight?: number\n  /** Extra space between neighbouring cards along the helix axis, in px. */\n  gap?: number\n  /** Card corner radius in px. */\n  radius?: number\n\n  /* 3D motion */\n  /** How far the back strand sits behind the front strand, in px. */\n  depth?: number\n  /** Extra helix twist per card, in degrees. 0 gives a flat zig-zag. */\n  curve?: number\n  /** Vertical distance between the two strands, in px. */\n  spread?: number\n  /** CSS perspective distance in px. Lower is more dramatic. */\n  perspective?: number\n  /** Y-axis rotation applied per card away from the centre, in degrees. */\n  rotation?: number\n  /** Tilt of the whole helix axis, in degrees. */\n  tilt?: number\n\n  /* Image effects */\n  activeScale?: number\n  inactiveScale?: number\n  /** Blur in px per card of distance from the centre. */\n  blur?: number\n  /** Chromatic-aberration offset in px. Also grows with scroll speed. */\n  rgbSplit?: number\n  inactiveOpacity?: number\n  shadow?: boolean\n  /** Shadow opacity, 0 to 1. */\n  shadowStrength?: number\n\n  /* Interaction */\n  dragSensitivity?: number\n  /** Momentum kept per frame after a flick, 0 to 1. Higher coasts longer. */\n  inertia?: number\n  /** How quickly cards settle into place, 0 to 1. */\n  snapStrength?: number\n  autoplay?: boolean\n  /** Autoplay speed in cards per second. Negative values run backwards. */\n  autoplaySpeed?: number\n  pauseOnHover?: boolean\n  /** Scroll the carousel with the mouse wheel or trackpad while hovering it. */\n  wheel?: boolean\n  grabCursor?: boolean\n  /** How many cards stay visible on each side of the active one. */\n  visibleItems?: number\n\n  /** Called with the index (into `images`) of the card that becomes active. */\n  onIndexChange?: (index: number) => void\n}\n\nconst DEFAULTS = {\n  itemWidth: 220,\n  itemHeight: 300,\n  gap: 20,\n  radius: 14,\n  depth: 260,\n  curve: 24,\n  spread: 70,\n  perspective: 1100,\n  rotation: 22,\n  tilt: -6,\n  activeScale: 1,\n  inactiveScale: 0.8,\n  blur: 1.5,\n  rgbSplit: 0,\n  inactiveOpacity: 0.8,\n  shadow: true,\n  shadowStrength: 0.35,\n  dragSensitivity: 1,\n  inertia: 0.93,\n  snapStrength: 0.08,\n  autoplay: true,\n  autoplaySpeed: 0.15,\n  pauseOnHover: true,\n  wheel: true,\n  grabCursor: true,\n  visibleItems: 4,\n}\n\ntype Settings = typeof DEFAULTS\ntype Config = Settings & { fit: number; reduced: boolean }\ntype Mode = \"idle\" | \"drag\" | \"coast\" | \"snap\" | \"auto\"\n\nconst RESUME_DELAY = 2500\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\nconst smoothstep = (e0: number, e1: number, x: number) => {\n  const t = clamp((x - e0) / (e1 - e0), 0, 1)\n  return t * t * (3 - 2 * t)\n}\n/** Signed distance from `pos` to `slot` on a loop of `n` slots, in [-n/2, n/2). */\nconst loopOffset = (slot: number, pos: number, n: number) => mod(slot - pos + n / 2, n) - n / 2\n\n/** Distance between neighbouring cards along the helix axis, before fitting. */\nconst stepOf = (c: Settings) => c.itemWidth / 2 + c.gap\n\n/**\n * Places one card on the helix. `p` is its signed distance from the centre in cards.\n * The twist is a half turn per card plus `curve`, so neighbours always sit on opposite\n * strands and whichever card reaches the centre comes to the front.\n */\nfunction placeCard(p: number, velocity: number, c: Config): React.CSSProperties {\n  const a = Math.abs(p)\n  const f = c.fit\n  const theta = p * (Math.PI + (c.curve * Math.PI) / 180)\n\n  const u = p * stepOf(c)\n  const v = Math.sin(theta) * c.spread\n  const z = ((Math.cos(theta) - 1) / 2) * c.depth - a * c.depth * 0.08\n\n  const t = (c.tilt * Math.PI) / 180\n  const x = u * Math.cos(t) - v * Math.sin(t)\n  const y = u * Math.sin(t) + v * Math.cos(t)\n\n  const active = Math.max(0, 1 - a)\n  const scale = c.inactiveScale + (c.activeScale - c.inactiveScale) * active\n  const rotY = clamp(-p * c.rotation, -80, 80)\n  const fade = 1 - smoothstep(c.visibleItems - 0.5, c.visibleItems + 0.5, a)\n\n  const filters: string[] = []\n  if (!c.reduced) {\n    const blur = c.blur * Math.min(a, 3)\n    if (blur > 0.05) filters.push(`blur(${blur.toFixed(2)}px)`)\n    const split = c.rgbSplit * (Math.min(a, 1.5) + Math.min(Math.abs(velocity), 8) * 0.25)\n    if (split > 0.1) {\n      const s = split.toFixed(2)\n      filters.push(\n        `drop-shadow(${s}px 0 0 rgba(255,0,80,0.55))`,\n        `drop-shadow(-${s}px 0 0 rgba(0,190,255,0.55))`\n      )\n    }\n  }\n\n  return {\n    transform: `translate3d(${(x * f).toFixed(2)}px, ${(y * f).toFixed(2)}px, ${(z * f).toFixed(2)}px) rotateZ(${c.tilt}deg) rotateY(${rotY.toFixed(2)}deg) scale(${(scale * f).toFixed(4)})`,\n    opacity: (c.inactiveOpacity + (1 - c.inactiveOpacity) * active) * fade,\n    filter: filters.length ? filters.join(\" \") : \"none\",\n    boxShadow: c.shadow\n      ? `0 24px 48px -12px rgba(0,0,0,${(c.shadowStrength * (0.4 + 0.6 * active)).toFixed(3)})`\n      : \"none\",\n    zIndex: Math.round(5000 + z * 4 - a * 2),\n    visibility: fade <= 0 ? \"hidden\" : \"visible\",\n  }\n}\n\n/** Repeats the images so the loop is long enough to fill both sides of the helix. */\nfunction buildSlots(count: number, visibleItems: number) {\n  if (count === 0) return []\n  const min = Math.ceil(visibleItems) * 2 + 2\n  let total = count\n  while (total < min) total += 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 DnaCarousel({\n  images,\n  onIndexChange,\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"Image carousel\",\n  ...props\n}: DnaCarouselProps) {\n  const { settings: s, rest } = splitProps(props)\n\n  const slots = React.useMemo(\n    () => buildSlots(images.length, s.visibleItems),\n    [images.length, s.visibleItems]\n  )\n\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const liveRef = React.useRef<HTMLDivElement>(null)\n  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([])\n  const settingsRef = React.useRef(s)\n  const slotsRef = React.useRef(slots)\n  const imagesRef = React.useRef(images)\n  const onIndexChangeRef = React.useRef(onIndexChange)\n  const state = React.useRef({\n    pos: 0,\n    vel: 0,\n    target: 0,\n    mode: \"idle\" as Mode,\n    resumeAt: 0,\n    dirty: true,\n    hovering: false,\n    visible: true,\n    reduced: false,\n    fit: 1,\n    lastIndex: -1,\n  })\n  const drag = React.useRef<{\n    id: number\n    startX: number\n    startPos: number\n    moved: boolean\n    slot: number | null\n    samples: { t: number; pos: number }[]\n  } | null>(null)\n\n  React.useEffect(() => {\n    settingsRef.current = s\n    slotsRef.current = slots\n    imagesRef.current = images\n    onIndexChangeRef.current = onIndexChange\n    state.current.dirty = true\n  })\n\n  const settle = React.useCallback((target: number) => {\n    const st = state.current\n    st.target = target\n    st.mode = \"snap\"\n    st.resumeAt = performance.now() + RESUME_DELAY\n  }, [])\n\n  const goToSlot = (slot: number) => {\n    const st = state.current\n    settle(Math.round(st.pos + loopOffset(slot, st.pos, slotsRef.current.length)))\n  }\n\n  const stepBy = (delta: number) => {\n    const st = state.current\n    const base = st.mode === \"snap\" ? st.target : Math.round(st.pos)\n    settle(base + delta)\n  }\n\n  // Reduced motion: no autoplay, blur or colour split, 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 helix 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 / (c.itemWidth * 3), height / (c.itemHeight + c.spread * 2 + 60))\n      state.current.fit = clamp(fit, 0.35, 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 !== \"coast\" && st.mode !== \"auto\") st.vel = 0\n      st.vel = clamp(st.vel + (delta / stepOf(c)) * 6 * c.dragSensitivity, -25, 25)\n      st.mode = \"coast\"\n      st.resumeAt = performance.now() + RESUME_DELAY\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 canAutoplay = (c: Config, now: number) =>\n      c.autoplay &&\n      c.autoplaySpeed !== 0 &&\n      !c.reduced &&\n      now >= state.current.resumeAt &&\n      !(c.pauseOnHover && state.current.hovering)\n\n    const render = (c: Config) => {\n      const st = state.current\n      const n = slotsRef.current.length\n      const root = rootRef.current\n      if (root) root.style.perspective = `${c.perspective * c.fit}px`\n      for (let i = 0; i < n; i++) {\n        const el = itemRefs.current[i]\n        if (el) Object.assign(el.style, placeCard(loopOffset(i, st.pos, n), st.vel, c))\n      }\n\n      const activeSlot = mod(Math.round(st.pos), n)\n      if (activeSlot !== st.lastIndex) {\n        itemRefs.current[st.lastIndex]?.setAttribute(\"aria-hidden\", \"true\")\n        itemRefs.current[activeSlot]?.setAttribute(\"aria-hidden\", \"false\")\n        st.lastIndex = activeSlot\n        const index = slotsRef.current[activeSlot]\n        onIndexChangeRef.current?.(index)\n        if (st.mode !== \"auto\" && liveRef.current) {\n          const img = imagesRef.current[index]\n          liveRef.current.textContent = `${img?.alt || \"Image\"}, ${index + 1} of ${imagesRef.current.length}`\n        }\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      if (!st.visible || !slotsRef.current.length || dt <= 0) return\n      const c: Config = { ...settingsRef.current, fit: st.fit, reduced: st.reduced }\n      const prev = st.pos\n\n      switch (st.mode) {\n        case \"coast\": {\n          st.pos += st.vel * dt\n          st.vel *= Math.pow(clamp(c.inertia, 0, 0.999), dt * 60)\n          if (Math.abs(st.vel) < 1.2) {\n            st.target = Math.round(st.pos + st.vel * 0.12)\n            st.mode = \"snap\"\n          }\n          break\n        }\n        case \"snap\": {\n          const k = c.reduced ? 0.4 : clamp(c.snapStrength, 0.01, 1)\n          st.pos += (st.target - st.pos) * (1 - Math.pow(1 - k, dt * 60))\n          st.vel = (st.pos - prev) / dt\n          if (Math.abs(st.target - st.pos) < 0.0005) {\n            st.pos = st.target\n            st.vel = 0\n            st.mode = \"idle\"\n            st.dirty = true\n          }\n          break\n        }\n        case \"auto\": {\n          if (canAutoplay(c, now)) {\n            st.pos += c.autoplaySpeed * dt\n            st.vel = c.autoplaySpeed\n          } else {\n            st.target = c.autoplaySpeed > 0 ? Math.ceil(st.pos) : Math.floor(st.pos)\n            st.mode = \"snap\"\n          }\n          break\n        }\n        case \"idle\": {\n          if (canAutoplay(c, now)) st.mode = \"auto\"\n          break\n        }\n      }\n\n      if (st.pos !== prev || st.dirty || st.mode === \"drag\") {\n        st.dirty = false\n        render(c)\n      }\n    }\n\n    raf = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(raf)\n  }, [])\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || !slots.length) return\n    const st = state.current\n    const slotAttr = (e.target as HTMLElement).closest<HTMLElement>(\"[data-slot]\")?.dataset.slot\n    e.currentTarget.setPointerCapture(e.pointerId)\n    drag.current = {\n      id: e.pointerId,\n      startX: e.clientX,\n      startPos: st.pos,\n      moved: false,\n      slot: slotAttr != null ? Number(slotAttr) : null,\n      samples: [{ t: performance.now(), pos: st.pos }],\n    }\n    st.mode = \"drag\"\n    st.vel = 0\n    st.resumeAt = Infinity\n    if (s.grabCursor) e.currentTarget.style.cursor = \"grabbing\"\n  }\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current\n    if (!d || d.id !== e.pointerId) return\n    const dx = e.clientX - d.startX\n    if (!d.moved && Math.abs(dx) > 4) d.moved = true\n    const st = state.current\n    st.pos = d.startPos - (dx / (stepOf(s) * st.fit)) * s.dragSensitivity\n    const now = performance.now()\n    d.samples.push({ t: now, pos: st.pos })\n    while (d.samples.length > 2 && now - d.samples[0].t > 100) d.samples.shift()\n    const first = d.samples[0]\n    st.vel = now > first.t ? ((st.pos - first.pos) / (now - first.t)) * 1000 : 0\n  }\n\n  const endDrag = (e: React.PointerEvent<HTMLDivElement>, cancelled: boolean) => {\n    const d = drag.current\n    if (!d || d.id !== e.pointerId) return\n    drag.current = null\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    st.resumeAt = performance.now() + RESUME_DELAY\n    if (!d.moved && !cancelled && d.slot != null) {\n      goToSlot(d.slot)\n      return\n    }\n    const stale = performance.now() - d.samples[d.samples.length - 1].t > 80\n    st.vel = stale ? 0 : clamp(st.vel, -30, 30)\n    st.mode = \"coast\"\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"ArrowRight\") stepBy(1)\n    else if (e.key === \"ArrowLeft\") stepBy(-1)\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        perspective: `${s.perspective}px`,\n        touchAction: \"pan-y\",\n        cursor: s.grabCursor ? \"grab\" : undefined,\n        ...style,\n      }}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={(e) => endDrag(e, false)}\n      onPointerCancel={(e) => endDrag(e, true)}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\") state.current.hovering = true\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"mouse\") state.current.hovering = false\n      }}\n      onKeyDown={onKeyDown}\n    >\n      {slots.map((imageIndex, i) => {\n        const image = images[imageIndex]\n        const p = loopOffset(i, 0, slots.length)\n        return (\n          <div\n            key={i}\n            ref={(el) => {\n              itemRefs.current[i] = el\n            }}\n            data-slot={i}\n            aria-hidden={Math.round(p) !== 0}\n            style={{\n              position: \"absolute\",\n              left: \"50%\",\n              top: \"50%\",\n              width: s.itemWidth,\n              height: s.itemHeight,\n              marginLeft: -s.itemWidth / 2,\n              marginTop: -s.itemHeight / 2,\n              borderRadius: s.radius,\n              overflow: \"hidden\",\n              backfaceVisibility: \"hidden\",\n              willChange: \"transform, opacity, filter\",\n              background: \"var(--muted, #111)\",\n              ...placeCard(p, 0, initial),\n            }}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img\n              src={image.src}\n              alt={image.alt ?? \"\"}\n              draggable={false}\n              // Lazy for every card: the browser still fetches the visible ones before they\n              // scroll into view, and a carousel below the fold no longer loads with the page.\n              loading=\"lazy\"\n              decoding=\"async\"\n              className=\"pointer-events-none block size-full object-cover select-none\"\n            />\n          </div>\n        )\n      })}\n      <div ref={liveRef} aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" />\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-23"
  },
  "type": "registry:component"
}