{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-carousel",
  "type": "registry:ui",
  "title": "Team Carousel",
  "description": "An infinite Embla team carousel that pauses on hover and highlights the hovered member.",
  "files": [
    {
      "path": "src/registry/ui/team-carousel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport Image from \"next/image\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport AutoScroll from \"embla-carousel-auto-scroll\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TeamMember = {\n  name: string;\n  role: string;\n  location?: string;\n  image: { src: string; alt?: string };\n};\n\nexport type TeamCarouselProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  members: TeamMember[];\n  title?: React.ReactNode;\n  /** Pinned above the carousel, e.g. a brand mark and nav links. */\n  header?: React.ReactNode;\n  /** Pixels per frame of the continuous scroll. */\n  speed?: number;\n  /** Fade and progressively blur the cards into the edges. */\n  edgeBlur?: boolean;\n};\n\n// Embla needs enough slides to fill the viewport before it can loop.\nconst MIN_SLIDES = 12;\nconst EASE_OUT = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\nexport function TeamCarousel({\n  members,\n  title = \"Our Team\",\n  header,\n  speed = 0.6,\n  edgeBlur = true,\n  className,\n  ...props\n}: TeamCarouselProps) {\n  const reduceMotion = useReducedMotion();\n\n  // The plugin's own pause/resume would override a user's Pause, so all of that is off\n  // and playback is driven by `shouldPlay` below.\n  const plugins = useMemo(\n    () => [\n      AutoScroll({\n        speed,\n        playOnInit: false,\n        stopOnInteraction: true,\n        stopOnMouseEnter: false,\n        stopOnFocusIn: false,\n      }),\n    ],\n    [speed]\n  );\n  const [viewportRef, emblaApi] = useEmblaCarousel(\n    { loop: true, dragFree: true, align: \"start\" },\n    plugins\n  );\n\n  const [active, setActive] = useState<number | null>(null);\n  const [hovering, setHovering] = useState(false);\n  const [focusWithin, setFocusWithin] = useState(false);\n  const [dragging, setDragging] = useState(false);\n  // null until the user chooses; reduced motion starts paused, but Play still works.\n  const [userPaused, setUserPaused] = useState<boolean | null>(null);\n  const paused = userPaused ?? !!reduceMotion;\n  const shouldPlay = !paused && !hovering && !focusWithin && !dragging;\n\n  useEffect(() => {\n    const autoScroll = emblaApi?.plugins().autoScroll;\n    if (!autoScroll) return;\n    if (shouldPlay) autoScroll.play();\n    else autoScroll.stop();\n  }, [emblaApi, shouldPlay]);\n\n  // A drag stops the scroll; it resumes once the carousel has settled.\n  useEffect(() => {\n    if (!emblaApi) return;\n    const onDown = () => setDragging(true);\n    const onSettle = () => setDragging(false);\n    emblaApi.on(\"pointerDown\", onDown).on(\"settle\", onSettle);\n    return () => {\n      emblaApi.off(\"pointerDown\", onDown).off(\"settle\", onSettle);\n    };\n  }, [emblaApi]);\n\n  const slides = useMemo(() => {\n    const copies = Math.max(1, Math.ceil(MIN_SLIDES / Math.max(members.length, 1)));\n    return Array.from({ length: copies }, () => members).flat();\n  }, [members]);\n\n  const divisions = useMemo(() => [...new Set(members.map((member) => member.role))], [members]);\n  const activeMember = active === null ? null : slides[active];\n\n  return (\n    <section\n      data-slot=\"team-carousel\"\n      aria-roledescription=\"carousel\"\n      aria-label={typeof title === \"string\" ? title : \"Team\"}\n      className={cn(\n        \"flex w-full flex-col overflow-hidden rounded-xl bg-black py-6 text-white sm:py-8\",\n        className\n      )}\n      {...props}\n    >\n      {header && <div className=\"px-6 sm:px-8\">{header}</div>}\n\n      <div className=\"relative\">\n        {/* Vertical padding leaves room for the labels above and below the active card. */}\n        <div\n          ref={viewportRef}\n          className=\"cursor-grab overflow-hidden py-16 active:cursor-grabbing\"\n          // Keyboard focus on a card pauses the scroll; scoped here so the Play button doesn't.\n          onFocus={() => setFocusWithin(true)}\n          onBlur={(event) => {\n            if (!event.currentTarget.contains(event.relatedTarget)) {\n              setFocusWithin(false);\n              setActive(null);\n            }\n          }}\n          onPointerEnter={(event) => event.pointerType === \"mouse\" && setHovering(true)}\n          onPointerLeave={() => {\n            setHovering(false);\n            setActive(null);\n          }}\n        >\n          <div className=\"-ml-4 flex touch-pan-y\">\n            {slides.map((member, index) => {\n              const isActive = index === active;\n              const number = (index % members.length) + 1;\n              return (\n                <div key={index} className=\"relative min-w-0 flex-[0_0_auto] pl-4\">\n                  <p\n                    aria-hidden\n                    className={cn(\n                      \"absolute bottom-full left-4 mb-5 text-[0.6875rem] font-medium tracking-wide whitespace-nowrap uppercase\",\n                      \"transition-[opacity,transform] duration-200\",\n                      EASE_OUT,\n                      isActive ? \"opacity-100\" : \"translate-y-1 opacity-0 motion-reduce:translate-y-0\"\n                    )}\n                  >\n                    {pad(number)}. {member.name}\n                  </p>\n\n                  <button\n                    type=\"button\"\n                    aria-label={`${member.name}, ${member.role}${member.location ? `, ${member.location}` : \"\"}`}\n                    onPointerEnter={(event) => event.pointerType === \"mouse\" && setActive(index)}\n                    onFocus={() => setActive(index)}\n                    // Taps don't hover, so a tap selects the card instead.\n                    onClick={() => setActive(index)}\n                    className={cn(\n                      \"relative block aspect-[3/4] w-28 cursor-pointer overflow-hidden bg-neutral-900 outline-none sm:w-36\",\n                      \"transition-[transform,opacity] duration-300\",\n                      EASE_OUT,\n                      \"focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black\",\n                      isActive && \"z-10 scale-110 motion-reduce:scale-100\",\n                      active !== null && !isActive && \"opacity-45\"\n                    )}\n                  >\n                    <Image\n                      src={member.image.src}\n                      alt={member.image.alt ?? \"\"}\n                      fill\n                      sizes=\"(min-width: 640px) 168px, 132px\"\n                      draggable={false}\n                      className={cn(\n                        \"object-cover transition-[filter] duration-300\",\n                        isActive ? \"grayscale-0\" : \"grayscale\"\n                      )}\n                    />\n                  </button>\n\n                  <p\n                    aria-hidden\n                    className={cn(\n                      \"absolute top-full left-4 mt-5 text-[0.6875rem] leading-snug font-medium tracking-wide whitespace-nowrap text-white/70 uppercase\",\n                      \"transition-[opacity,transform] duration-200\",\n                      EASE_OUT,\n                      isActive ? \"opacity-100\" : \"-translate-y-1 opacity-0 motion-reduce:translate-y-0\"\n                    )}\n                  >\n                    {member.role}\n                    {member.location && (\n                      <>\n                        <br />\n                        {member.location}\n                      </>\n                    )}\n                  </p>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n        {edgeBlur && (\n          <>\n            <EdgeBlur side=\"left\" />\n            <EdgeBlur side=\"right\" />\n          </>\n        )}\n      </div>\n\n      <div className=\"mt-auto grid grid-cols-1 items-end gap-6 px-6 sm:grid-cols-3 sm:px-8\">\n        <h3 className=\"text-3xl font-light tracking-tight uppercase sm:text-4xl\">{title}</h3>\n        <div className=\"flex items-center gap-4 text-[0.6875rem] tracking-wide uppercase sm:justify-center\">\n          <p>\n            We have {divisions.length} division{divisions.length === 1 ? \"\" : \"s\"}\n          </p>\n          <button\n            type=\"button\"\n            onClick={() => setUserPaused(!paused)}\n            aria-pressed={paused}\n            className=\"cursor-pointer rounded-sm tracking-wide text-white/50 uppercase underline-offset-4 outline-none transition-colors hover:text-white hover:underline focus-visible:ring-2 focus-visible:ring-white\"\n          >\n            {paused ? \"Play\" : \"Pause\"}\n          </button>\n        </div>\n        <ul aria-label=\"Divisions\" className=\"text-[0.6875rem] leading-relaxed tracking-wide uppercase sm:justify-self-end\">\n          {divisions.map((division) => (\n            <li\n              key={division}\n              className={cn(\n                \"transition-colors duration-200\",\n                // Highlights the active member's division; all read equally when nothing is active.\n                activeMember\n                  ? activeMember.role === division\n                    ? \"text-white\"\n                    : \"text-white/35\"\n                  : \"text-white/60\"\n              )}\n            >\n              {division}\n            </li>\n          ))}\n        </ul>\n      </div>\n    </section>\n  );\n}\n\n// Stacked backdrop blurs, each masked a little closer to the edge, so the blur ramps up\n// smoothly instead of starting at a hard line. A black fade on top melts cards into the stage.\nconst BLUR_LAYERS = [\n  { blur: 1, from: 0, to: 35 },\n  { blur: 2, from: 20, to: 55 },\n  { blur: 4, from: 40, to: 75 },\n  { blur: 8, from: 60, to: 100 },\n];\n\nfunction EdgeBlur({ side }: { side: \"left\" | \"right\" }) {\n  // The gradient runs from the inner side of the strip out to the edge.\n  const toward = side === \"left\" ? \"to left\" : \"to right\";\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute inset-y-0 z-20 w-[14%] min-w-16\",\n        side === \"left\" ? \"left-0\" : \"right-0\"\n      )}\n    >\n      {BLUR_LAYERS.map(({ blur, from, to }) => {\n        const mask = `linear-gradient(${toward}, transparent ${from}%, black ${to}%)`;\n        return (\n          <div\n            key={blur}\n            className=\"absolute inset-0\"\n            style={{ backdropFilter: `blur(${blur}px)`, WebkitBackdropFilter: `blur(${blur}px)`, maskImage: mask, WebkitMaskImage: mask }}\n          />\n        );\n      })}\n      <div className=\"absolute inset-0\" style={{ background: `linear-gradient(${toward}, transparent, black)` }} />\n    </div>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "embla-carousel-auto-scroll",
    "embla-carousel-react",
    "motion"
  ]
}
