{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "arc-carousel",
  "type": "registry:block",
  "title": "Arc Carousel",
  "description": "A showcase carousel whose cards hang on a downward arc, each one further out turned a little more. Stepping swings the whole arc round, the centre card easing up in size under a lime outline; cards fade out past the third slot and wrap round unseen. Swipe, drag, the arrow keys or a click on a side card all step it, and it autoplays with a pause button. GSAP with ScrollTrigger.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/arc-carousel.tsx",
      "type": "registry:component",
      "target": "components/tweenui/arc-carousel.tsx",
      "content": "'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type MouseEvent,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { CustomEase } from 'gsap/CustomEase';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, CustomEase, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface ArcCarouselItem {\n  title: string;\n  image: string;\n  /** Chip on the top left of the card. */\n  tag?: string;\n  /** Line under the title, e.g. a client and year or a price. */\n  meta?: string;\n  href?: string;\n}\n\nexport interface ArcCarouselProps extends ComponentPropsWithoutRef<'section'> {\n  /** Eight or more keep the wrap seamless. */\n  items?: ArcCarouselItem[];\n  /** Accessible name for the carousel. */\n  label?: string;\n  /** Step on its own; holds on hover, keyboard focus and while off-screen. */\n  autoplay?: boolean;\n  /** Time each card holds the centre while autoplaying, in ms. */\n  interval?: number;\n}\n\n// Card position by distance from the centre: px across, px down, degrees of turn\nconst SLOTS = [\n  { x: 0, y: 0, r: 0 },\n  { x: 359, y: 26, r: 8.77 },\n  { x: 711, y: 113, r: 18.83 },\n  { x: 1033, y: 243, r: 25.05 },\n];\nconst ACTIVE_SCALE = 1.05;\nconst MOVE_DURATION = 0.8;\nconst MOVE_EASE = CustomEase.create('arc-move', 'M0,0 C0.98,0.3 0.3,1 1,1');\nconst SWIPE = 50;\n\n/** Where a card sits at a (fractional) distance from the centre. Past the last slot it fades out. */\nconst slot = (offset: number) => {\n  const t = Math.abs(offset);\n  const sign = Math.sign(offset);\n  const i = Math.min(Math.floor(t), SLOTS.length - 2);\n  const a = SLOTS[i];\n  const b = SLOTS[i + 1];\n  const f = t - i;\n  return {\n    x: (a.x + (b.x - a.x) * f) * sign,\n    y: a.y + (b.y - a.y) * f,\n    rotation: (a.r + (b.r - a.r) * f) * sign,\n    scale: 1 + (ACTIVE_SCALE - 1) * gsap.utils.clamp(0, 1, 1 - t),\n    opacity: gsap.utils.clamp(0, 1, SLOTS.length - t),\n    zIndex: 100 - Math.round(t * 10),\n  };\n};\n\nconst pad2 = (n: number) => String(n).padStart(2, '0');\n\nconst photo = (id: string, width = 640) =>\n  `https://images.unsplash.com/photo-${id}?w=${width}&h=${Math.round(width * (430 / 312))}&fit=crop&q=80&auto=format`;\nconst srcSetOf = (src: string) =>\n  src.includes('images.unsplash.com')\n    ? `${src.replace(/([?&])w=\\d+&h=\\d+/, '$1w=320&h=441')} 1x, ${src} 2x`\n    : undefined;\n\nconst DEFAULT_ITEMS: ArcCarouselItem[] = [\n  {\n    title: 'Nightfall',\n    tag: 'Astronomy app',\n    meta: 'Stellar Co. · 2026',\n    image: photo('1519681393784-d120267933ba'),\n  },\n  {\n    title: 'Chroma Identity',\n    tag: 'Brand identity',\n    meta: 'Chroma Studio · 2026',\n    image: photo('1541701494587-cb58502866ab'),\n  },\n  {\n    title: 'Alpine Lodge',\n    tag: 'Hospitality',\n    meta: 'Moraine Hotels · 2026',\n    image: photo('1493246507139-91e8fad9978e'),\n  },\n  {\n    title: 'Metro Pulse',\n    tag: 'Data visualisation',\n    meta: 'City of Lakes · 2025',\n    image: photo('1477959858617-67f85cf4f1df'),\n  },\n  {\n    title: 'Orbit Motion',\n    tag: '3D & motion',\n    meta: 'Orbit Labs · 2025',\n    image: photo('1618005198919-d3d4b5a92ead'),\n  },\n  {\n    title: 'Open Road',\n    tag: 'Campaign film',\n    meta: 'Westward · 2025',\n    image: photo('1500530855697-b586d89ba3ee'),\n  },\n  {\n    title: 'Still Water',\n    tag: 'Wellness app',\n    meta: 'Calm Harbor · 2024',\n    image: photo('1534447677768-be436bb09401'),\n  },\n  {\n    title: 'Lago Retreats',\n    tag: 'Travel platform',\n    meta: 'Lago Travel · 2024',\n    image: photo('1501785888041-af3ef285b470'),\n  },\n];\n\nconst STEP = cn(\n  'grid size-11 place-items-center rounded-full border border-[#9fd4d6]/25 text-white',\n  'transition-[background-color,border-color,color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] hover:border-[#c6f56f] hover:bg-[#c6f56f] hover:text-[#03110f]',\n  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#c6f56f] motion-reduce:transition-none'\n);\nconst FADE =\n  'pointer-events-none absolute inset-y-0 z-2 w-[clamp(2.5rem,14cqw,12rem)] from-[#03110f] via-[#03110f]/70 to-transparent';\n\nconst Caret = ({ flip }: { flip?: boolean }) => (\n  <svg\n    viewBox=\"0 0 9 16\"\n    aria-hidden=\"true\"\n    className={cn('h-4 w-[8.5px]', flip && 'rotate-180')}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M7.5 1.5 1.5 8l6 6.5\" />\n  </svg>\n);\n\nconst PauseIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <rect x=\"3.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n    <rect x=\"9.5\" y=\"2.5\" width=\"3\" height=\"11\" rx=\"1\" />\n  </svg>\n);\n\nconst PlayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\" className=\"size-3.5\" fill=\"currentColor\">\n    <path d=\"M4.5 2.9v10.2a1 1 0 0 0 1.53.85l8.1-5.1a1 1 0 0 0 0-1.7l-8.1-5.1A1 1 0 0 0 4.5 2.9Z\" />\n  </svg>\n);\n\nconst ArrowUpRight = ({ className }: { className?: string }) => (\n  <svg\n    viewBox=\"0 0 16 16\"\n    aria-hidden=\"true\"\n    className={className}\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"1.6\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n  </svg>\n);\n\nconst ArcCarousel = ({\n  items = DEFAULT_ITEMS,\n  label = 'Featured projects',\n  autoplay = true,\n  interval = 5000,\n  className,\n  ...props\n}: ArcCarouselProps) => {\n  const count = items.length;\n  const [position, setPosition] = useState(0);\n  const [playing, setPlaying] = useState(autoplay);\n  const [held, setHeld] = useState(false);\n  const rootRef = useRef<HTMLElement>(null);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const track = useRef({ pos: 0 });\n  const clockRef = useRef<gsap.core.Tween | null>(null);\n  const swipe = useRef({ tracking: false, swiped: false, x: 0, y: 0 });\n  const holds = useRef({ hover: false, focus: false, offscreen: false });\n\n  const current = count ? gsap.utils.wrap(0, count, position) : 0;\n  const active = items[current];\n  const offsetOf = (index: number, pos: number) =>\n    gsap.utils.wrap(-count / 2, count / 2, index - pos);\n\n  const step = useCallback((direction: number) => setPosition((p) => p + direction), []);\n\n  // `playing` is the viewer's choice; a hold is a pause they did not ask for\n  const hold = useCallback((key: keyof typeof holds.current, on: boolean) => {\n    holds.current[key] = on;\n    setHeld(Object.values(holds.current).some(Boolean));\n  }, []);\n\n  // Every card is placed from one number, so a step is a single tween and the wrap needs no copies\n  useGSAP(\n    () => {\n      const cards = cardRefs.current.filter((card): card is HTMLDivElement => card !== null);\n      const render = () =>\n        cards.forEach((card, i) => {\n          const s = slot(offsetOf(i, track.current.pos));\n          gsap.set(card, { ...s, pointerEvents: s.opacity > 0.5 ? 'auto' : 'none' });\n        });\n\n      gsap.to(track.current, {\n        pos: position,\n        duration: prefersReducedMotion() ? 0 : MOVE_DURATION,\n        ease: MOVE_EASE,\n        overwrite: true,\n        onUpdate: render,\n      });\n    },\n    { scope: rootRef, dependencies: [position, count] }\n  );\n\n  // The media query is client-only, so reduced motion applies after hydration\n  useEffect(() => {\n    setPlaying(autoplay && !prefersReducedMotion());\n  }, [autoplay]);\n\n  // The clock is the progress ring: it fills over `interval`, then steps. Any step restarts it\n  useGSAP(\n    () => {\n      const ring = ringRef.current;\n      if (!ring || !playing || count < 2) return;\n      clockRef.current = gsap.fromTo(\n        ring,\n        // The attribute, not the CSS property, so the dash stays in `pathLength` units\n        { attr: { 'stroke-dashoffset': 1 } },\n        {\n          attr: { 'stroke-dashoffset': 0 },\n          duration: interval / 1000,\n          ease: 'none',\n          onComplete: () => step(1),\n        }\n      );\n    },\n    { scope: rootRef, dependencies: [playing, position, interval, count], revertOnUpdate: true }\n  );\n\n  useEffect(() => {\n    if (held) clockRef.current?.pause();\n    else clockRef.current?.resume();\n  }, [held, position, playing]);\n\n  useGSAP(\n    () => {\n      if (!autoplay) return;\n      ScrollTrigger.create({\n        trigger: rootRef.current,\n        onToggle: (self) => hold('offscreen', !self.isActive),\n      });\n    },\n    { scope: rootRef, dependencies: [autoplay, hold] }\n  );\n\n  // A swipe can end outside the list\n  useEffect(() => {\n    const onUp = (event: PointerEvent) => {\n      const s = swipe.current;\n      if (!s.tracking) return;\n      s.tracking = false;\n      const dx = event.clientX - s.x;\n      if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(event.clientY - s.y)) {\n        s.swiped = true;\n        step(dx < 0 ? 1 : -1);\n      }\n    };\n    const onCancel = () => {\n      swipe.current.tracking = false;\n    };\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onCancel);\n    return () => {\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onCancel);\n    };\n  }, [step]);\n\n  // After a swipe, or on a side card, a click steps the arc instead of following the card's link\n  const onClickCapture = (event: MouseEvent<HTMLDivElement>) => {\n    if (swipe.current.swiped) {\n      swipe.current.swiped = false;\n      event.preventDefault();\n      event.stopPropagation();\n      return;\n    }\n    if (event.detail === 0) return;\n    const index = cardRefs.current.findIndex((card) => card?.contains(event.target as Node));\n    const offset = index < 0 ? 0 : Math.round(offsetOf(index, position));\n    if (offset !== 0) {\n      event.preventDefault();\n      event.stopPropagation();\n      step(Math.sign(offset));\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;\n    event.preventDefault();\n    step(event.key === 'ArrowRight' ? 1 : -1);\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      aria-label={label}\n      className={cn(\n        '@container relative isolate flex w-full flex-col items-center gap-4 overflow-hidden bg-[#03110f] py-16 text-white',\n        '@max-[48rem]:gap-8 @max-[48rem]:py-12',\n        className\n      )}\n      {...props}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-[62%] left-1/2 -z-10 size-[min(120cqw,70rem)] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,#045f64_45%,transparent),color-mix(in_oklch,#045f64_10%,transparent)_55%,transparent)]\"\n      />\n\n      {/* Isolated, so the cards' z-indexes stay under the edge fades */}\n      <div\n        className=\"relative isolate z-1 h-150 w-78 @max-[64rem]:h-134 @max-[48rem]:h-72.5\"\n        onPointerEnter={(event) => event.pointerType !== 'touch' && hold('hover', true)}\n        onPointerLeave={() => hold('hover', false)}\n      >\n        <div className=\"absolute inset-0 origin-[50%_0] @max-[64rem]:scale-80 @max-[48rem]:scale-62\">\n          <div\n            role=\"group\"\n            tabIndex={0}\n            aria-roledescription=\"carousel\"\n            aria-label={label}\n            className=\"absolute top-16 left-0 h-107.5 w-78 touch-pan-y rounded-[8px] outline-none select-none focus-visible:ring-2 focus-visible:ring-[#c6f56f] focus-visible:ring-offset-8 focus-visible:ring-offset-[#03110f] @max-[48rem]:top-2\"\n            onPointerDown={(event) => {\n              swipe.current = { tracking: true, swiped: false, x: event.clientX, y: event.clientY };\n            }}\n            onDragStart={(event) => event.preventDefault()}\n            onClickCapture={onClickCapture}\n            onKeyDown={onKeyDown}\n            onFocus={(event) => {\n              let visible = true;\n              try {\n                visible = event.target.matches(':focus-visible');\n              } catch {\n                // No :focus-visible support: treat every focus as visible\n              }\n              if (visible) hold('focus', true);\n            }}\n            onBlur={(event) => {\n              if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n                hold('focus', false);\n              }\n            }}\n          >\n            {items.map((item, i) => {\n              const isActive = i === current;\n              const Card = item.href ? 'a' : 'div';\n              // Also set in markup, so the arc is in place before hydration\n              const first = slot(offsetOf(i, 0));\n              return (\n                <div\n                  key={`${item.title}-${i}`}\n                  ref={(el) => {\n                    cardRefs.current[i] = el;\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${i + 1} of ${count}: ${item.title}`}\n                  aria-hidden={isActive ? undefined : true}\n                  data-active={isActive ? '' : undefined}\n                  className=\"group/card absolute top-0 left-0 h-107.5 w-78 will-change-transform\"\n                  style={{\n                    transform: `translate(${first.x}px, ${first.y}px) rotate(${first.rotation}deg) scale(${first.scale})`,\n                    opacity: first.opacity,\n                    zIndex: first.zIndex,\n                  }}\n                >\n                  <Card\n                    {...(item.href ? { href: item.href } : {})}\n                    tabIndex={item.href && isActive ? 0 : -1}\n                    draggable={false}\n                    className={cn(\n                      'relative block size-full overflow-hidden rounded-[8px] bg-[#071d1b]',\n                      'shadow-[0_30px_60px_-24px_rgb(0_0_0/0.75)]',\n                      'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#c6f56f]'\n                    )}\n                  >\n                    <img\n                      src={item.image}\n                      srcSet={srcSetOf(item.image)}\n                      alt=\"\"\n                      width={312}\n                      height={430}\n                      draggable={false}\n                      decoding=\"async\"\n                      loading={Math.abs(offsetOf(i, 0)) <= 2 ? 'eager' : 'lazy'}\n                      className=\"absolute inset-0 size-full object-cover\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 bg-[#03110f]/40 transition-opacity duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-active/card:opacity-0 motion-reduce:transition-none\"\n                    />\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t from-[#03110f] via-[#03110f]/60 to-transparent\"\n                    />\n\n                    <span className=\"absolute inset-x-4 top-4 flex items-center justify-between gap-3\">\n                      {item.tag ? (\n                        <span className=\"truncate rounded-full bg-[#03110f]/70 px-3 py-1.5 text-[11px] leading-none font-medium tracking-[0.12em] text-white uppercase ring-1 ring-white/10\">\n                          {item.tag}\n                        </span>\n                      ) : (\n                        <span />\n                      )}\n                      <span className=\"shrink-0 text-xs font-medium text-white/70 tabular-nums\">\n                        {pad2(i + 1)}\n                        <span className=\"text-white/35\"> / {pad2(count)}</span>\n                      </span>\n                    </span>\n\n                    <span className=\"absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 p-5\">\n                      <span className=\"grid min-w-0 gap-1.5\">\n                        <span className=\"truncate text-[22px] leading-tight font-semibold tracking-[-0.025em] text-white\">\n                          {item.title}\n                        </span>\n                        {item.meta && (\n                          <span className=\"truncate text-sm text-[#9fd4d6]/80\">{item.meta}</span>\n                        )}\n                      </span>\n                      {item.href && (\n                        <span\n                          aria-hidden=\"true\"\n                          className={cn(\n                            'grid size-11 shrink-0 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/20',\n                            'transition-[background-color,color,box-shadow] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none',\n                            'group-data-active/card:bg-[#c6f56f] group-data-active/card:text-[#03110f] group-data-active/card:ring-transparent'\n                          )}\n                        >\n                          <ArrowUpRight className=\"size-4 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-hover/card:rotate-45 motion-reduce:transition-none\" />\n                        </span>\n                      )}\n                    </span>\n\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"pointer-events-none absolute inset-0 rounded-[8px] ring-1 ring-white/10 transition-shadow duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] ring-inset group-data-active/card:ring-2 group-data-active/card:ring-[#c6f56f]/80 motion-reduce:transition-none\"\n                    />\n                  </Card>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"relative z-3 flex items-center gap-2\">\n        <button\n          className={STEP}\n          type=\"button\"\n          aria-label=\"Previous project\"\n          onClick={() => step(-1)}\n        >\n          <Caret />\n        </button>\n        <button className={STEP} type=\"button\" aria-label=\"Next project\" onClick={() => step(1)}>\n          <Caret flip />\n        </button>\n        {autoplay && (\n          <button\n            className={cn(STEP, 'relative ml-2')}\n            type=\"button\"\n            aria-label={playing ? 'Pause autoplay' : 'Start autoplay'}\n            onClick={() => setPlaying((on) => !on)}\n          >\n            {playing ? <PauseIcon /> : <PlayIcon />}\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 44 44\"\n              className={cn(\n                'pointer-events-none absolute -inset-px -rotate-90 transition-opacity duration-300',\n                playing ? 'opacity-100' : 'opacity-0'\n              )}\n            >\n              <circle\n                ref={ringRef}\n                cx=\"22\"\n                cy=\"22\"\n                r=\"21\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n                pathLength={1}\n                strokeDasharray=\"1\"\n                strokeDashoffset=\"1\"\n                className=\"text-[#c6f56f]\"\n              />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      {/* Quiet while autoplaying, so a screen reader is not interrupted every few seconds */}\n      <p aria-live={playing && !held ? 'off' : 'polite'} className=\"sr-only\">\n        {active ? `${current + 1} of ${count}: ${active.title}` : ''}\n      </p>\n\n      <div aria-hidden=\"true\" className={cn(FADE, 'left-0 bg-linear-to-r')} />\n      <div aria-hidden=\"true\" className={cn(FADE, 'right-0 bg-linear-to-l')} />\n    </section>\n  );\n};\n\nexport default ArcCarousel;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/arc-carousel\nGSAP plugins (CustomEase, ScrollTrigger) are registered inside the component, so there is nothing to set up.\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping.\nPass your own `items`; eight or more keep the arc full and the wrap unseen. Colors are inline Tailwind classes, so there is nothing to add to your theme."
}