{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "case-study-carousel",
  "type": "registry:ui",
  "title": "Case Study Carousel",
  "description": "A stacked case-study carousel with peeking neighbours, arrows, dots, swipe and keyboard support.",
  "files": [
    {
      "path": "src/registry/ui/case-study-carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type PanInfo,\n} from \"motion/react\";\nimport { ArrowRight, ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CaseStudy = {\n  /** Small mark above the title, e.g. a client logo or a category. */\n  brand: React.ReactNode;\n  title: string;\n  description: React.ReactNode;\n  href: string;\n  image: { src: string; alt: string };\n  stats: { value: string; label: string }[];\n};\n\nexport type CaseStudyCarouselProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  items: CaseStudy[];\n  eyebrow?: React.ReactNode;\n  /** Number shown before the eyebrow, e.g. \"06\". */\n  index?: string;\n  title?: React.ReactNode;\n  cta?: { label: string; href: string };\n  readMoreLabel?: string;\n  /** Any CSS color for the active dot and focus rings. */\n  accent?: string;\n};\n\nconst SLIDE = { type: \"spring\", duration: 0.6, bounce: 0.15 } as const;\n// A swipe counts once it's far enough, or quick enough.\nconst SWIPE_DISTANCE = 60;\nconst SWIPE_VELOCITY = 400;\n\nexport function CaseStudyCarousel({\n  items,\n  eyebrow = \"Case Studies\",\n  index,\n  title = \"What We've Built\",\n  cta,\n  readMoreLabel = \"Read More\",\n  accent = \"#0A6EFF\",\n  className,\n  style,\n  ...props\n}: CaseStudyCarouselProps) {\n  const reduceMotion = useReducedMotion();\n  const [active, setActive] = useState(0);\n  const count = items.length;\n  const go = (step: number) => setActive((current) => (current + step + count) % count);\n\n  // Shortest signed distance from the active card, wrapping around the ends.\n  const offsetOf = (i: number) => {\n    let offset = (i - active) % count;\n    if (offset > count / 2) offset -= count;\n    if (offset < -count / 2) offset += count;\n    return offset;\n  };\n\n  const handlePanEnd = (_: PointerEvent, info: PanInfo) => {\n    if (info.offset.x < -SWIPE_DISTANCE || info.velocity.x < -SWIPE_VELOCITY) go(1);\n    else if (info.offset.x > SWIPE_DISTANCE || info.velocity.x > SWIPE_VELOCITY) go(-1);\n  };\n\n  return (\n    <section\n      data-slot=\"case-study-carousel\"\n      style={{ \"--case-accent\": accent, ...style } as React.CSSProperties}\n      className={cn(\"@container w-full overflow-hidden rounded-[2.5rem] bg-white px-4 py-14 text-neutral-900 sm:px-8\", className)}\n      {...props}\n    >\n      <header className=\"flex flex-col items-center text-center\">\n        <span className=\"inline-flex items-center gap-2 rounded-full bg-neutral-100 px-3 py-1 text-[0.625rem] font-medium tracking-wide uppercase\">\n          {index && <span className=\"text-neutral-400\">{index}</span>}\n          <span aria-hidden className=\"size-1.5 rounded-full bg-neutral-900\" />\n          {eyebrow}\n        </span>\n        <h2 className=\"mt-4 text-4xl font-medium tracking-[-0.04em] @2xl:text-6xl\">{title}</h2>\n      </header>\n\n      <div\n        role=\"region\"\n        aria-roledescription=\"carousel\"\n        aria-label={typeof title === \"string\" ? title : \"Case studies\"}\n        tabIndex={0}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowRight\") go(1);\n          else if (event.key === \"ArrowLeft\") go(-1);\n        }}\n        className=\"relative mx-auto mt-12 max-w-5xl rounded-[2rem] outline-none focus-visible:ring-2 focus-visible:ring-[var(--case-accent)]\"\n      >\n        {/* All cards share one grid cell, so the stage is as tall as the tallest card. */}\n        {/* isolate: the cards' z-indexes stay inside this stack, so the arrows always sit above it. */}\n        <motion.div onPanEnd={handlePanEnd} className=\"isolate mx-auto grid w-[86%] touch-pan-y @3xl:w-[76%]\">\n          {items.map((item, i) => {\n            const offset = offsetOf(i);\n            return (\n              <CardShell\n                key={item.title}\n                offset={offset}\n                reduceMotion={!!reduceMotion}\n                onSelect={() => go(offset)}\n              >\n                <div className=\"relative aspect-square overflow-hidden rounded-[1.6rem] bg-neutral-900 shadow-[inset_0_1px_0_rgb(255_255_255/0.08)]\">\n                  <Image src={item.image.src} alt={item.image.alt} fill sizes=\"(min-width: 768px) 420px, 80vw\" className=\"object-cover\" draggable={false} />\n                </div>\n                <div className=\"flex flex-col px-4 pb-5 @2xl:py-6 @2xl:pr-6 @2xl:pl-0\">\n                  <div className=\"text-sm font-semibold tracking-wide\">{item.brand}</div>\n                  <h3 className=\"mt-6 text-2xl leading-tight font-medium tracking-tight text-balance\">{item.title}</h3>\n                  <p className=\"mt-3 text-sm leading-relaxed text-neutral-600\">{item.description}</p>\n                  <Link\n                    href={item.href}\n                    className=\"group mt-5 inline-flex w-fit items-center gap-2 rounded-sm text-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-2 focus-visible:ring-[var(--case-accent)]\"\n                  >\n                    {readMoreLabel}\n                    <span className=\"flex size-5 items-center justify-center rounded-full bg-neutral-300 text-white transition-[background-color,transform] duration-200 group-hover:translate-x-0.5 group-hover:bg-neutral-900\">\n                      <ArrowRight aria-hidden className=\"size-3\" />\n                    </span>\n                  </Link>\n                  <dl className=\"mt-auto grid grid-cols-3 gap-4 pt-8\">\n                    {item.stats.map((stat) => (\n                      // Number shown above its label; top-aligned so numbers line up when a label wraps.\n                      <div key={stat.label} className=\"flex flex-col-reverse justify-end gap-1\">\n                        <dt className=\"text-xs text-neutral-500\">{stat.label}</dt>\n                        <dd className=\"text-2xl font-medium tracking-tight\">{stat.value}</dd>\n                      </div>\n                    ))}\n                  </dl>\n                </div>\n              </CardShell>\n            );\n          })}\n        </motion.div>\n\n        <NavButton side=\"left\" label=\"Previous case study\" onClick={() => go(-1)} />\n        <NavButton side=\"right\" label=\"Next case study\" onClick={() => go(1)} />\n      </div>\n\n      <div className=\"mt-8 flex justify-center gap-1.5\">\n        {items.map((item, i) => (\n          <button\n            key={item.title}\n            type=\"button\"\n            onClick={() => setActive(i)}\n            aria-label={`Show ${item.title}`}\n            aria-current={i === active ? \"true\" : undefined}\n            className=\"group flex h-11 min-w-6 cursor-pointer items-center justify-center outline-none\"\n          >\n            <span\n              className={cn(\n                \"block h-1.5 rounded-full transition-[width,background-color] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] group-focus-visible:ring-2 group-focus-visible:ring-[var(--case-accent)]\",\n                i === active\n                  ? \"w-8 bg-[var(--case-accent)]\"\n                  : \"w-1.5 bg-neutral-200 group-hover:bg-neutral-300\"\n              )}\n            />\n          </button>\n        ))}\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`Case study ${active + 1} of ${count}: ${items[active]?.title}`}\n      </p>\n\n      {cta && (\n        <div className=\"mt-10 flex justify-center\">\n          <Link\n            href={cta.href}\n            className=\"rounded-full bg-neutral-950 px-5 py-2.5 text-sm font-medium text-white shadow-[0_0_0_1px_rgb(0_0_0),0_0_0_3px_color-mix(in_oklab,var(--case-accent)_35%,transparent)] outline-none transition-[background-color,transform] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-black focus-visible:ring-2 focus-visible:ring-[var(--case-accent)] focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:active:scale-100\"\n          >\n            {cta.label}\n          </Link>\n        </div>\n      )}\n    </section>\n  );\n}\n\n// One animated value drives everything about a card's place in the stack: its slide,\n// scale, veil and stacking order. Because z-index comes from the same value, cards swap\n// layers exactly as they pass each other, instead of popping at the first frame.\nfunction CardShell({\n  offset,\n  reduceMotion,\n  onSelect,\n  children,\n}: {\n  offset: number;\n  reduceMotion: boolean;\n  onSelect: () => void;\n  children: React.ReactNode;\n}) {\n  const isActive = offset === 0;\n  const position = useMotionValue(offset);\n\n  useEffect(() => {\n    const from = position.get();\n    // A card wrapping around the back would sweep across the front if animated; it's\n    // hidden at both ends, so move it instantly instead.\n    const wraps = Math.sign(from) !== Math.sign(offset) && from !== 0 && offset !== 0 && Math.max(Math.abs(from), Math.abs(offset)) >= 2;\n    if (reduceMotion || wraps) {\n      position.jump(offset);\n      return;\n    }\n    const controls = animate(position, offset, SLIDE);\n    return () => controls.stop();\n  }, [offset, position, reduceMotion]);\n\n  const distance = useTransform(position, (value) => Math.abs(value));\n  const transform = useTransform(position, (value) => {\n    const d = Math.abs(value);\n    // 1 at the centre, 0.84 for neighbours, 0.7 for cards waiting behind.\n    const scale = d <= 1 ? 1 - 0.16 * d : Math.max(0.7, 0.84 - 0.14 * (d - 1));\n    return `translateX(${value * 22}%) scale(${scale})`;\n  });\n  // Cards stay opaque (so they never show through each other); only those beyond the\n  // neighbours fade out, and neighbours are lightened with a white veil instead.\n  const opacity = useTransform(distance, [1, 2], [1, 0]);\n  const veil = useTransform(distance, [0, 1], [0, 0.55]);\n  const zIndex = useTransform(distance, (d) => Math.round(100 - d * 10));\n\n  return (\n    <motion.article\n      aria-hidden={!isActive}\n      inert={!isActive}\n      style={{ transform, opacity, zIndex }}\n      onClick={isActive ? undefined : onSelect}\n      className={cn(\n        \"relative grid grid-cols-1 gap-6 rounded-[2rem] bg-neutral-100 p-2 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.25)] ring-1 ring-black/5 [grid-area:1/1] @2xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)]\",\n        !isActive && \"cursor-pointer\"\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        style={{ opacity: veil }}\n        className=\"pointer-events-none absolute inset-0 rounded-[2rem] bg-white\"\n      />\n    </motion.article>\n  );\n}\n\nfunction NavButton({ side, label, onClick }: { side: \"left\" | \"right\"; label: string; onClick: () => void }) {\n  const Icon = side === \"left\" ? ChevronLeft : ChevronRight;\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className={cn(\n        \"absolute top-1/2 z-20 flex size-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-neutral-950 text-white shadow-lg ring-4 ring-white/80 outline-none\",\n        \"transition-[transform,background-color] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-black focus-visible:ring-[var(--case-accent)] active:scale-[0.94] motion-reduce:active:scale-100\",\n        side === \"left\" ? \"left-0 @3xl:left-[4%]\" : \"right-0 @3xl:right-[4%]\"\n      )}\n    >\n      <Icon aria-hidden className=\"size-4\" />\n    </button>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ]
}
