{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-carousel",
  "title": "Logo Carousel",
  "description": "Animated logo carousel with staggered cycling, image preloading, and reduced-motion support.",
  "dependencies": [
    "motion",
    "next"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/components/ui/logo-carousel.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// ── Types ───────────────────────────────────────────────────────────\n\ninterface LogoDef {\n  name: string;\n  /** Image logo; absent for text-only slides (e.g. \"& more\"). */\n  src?: string;\n  url: string;\n  width: number;\n  height: number;\n}\n\n// ── Logo data ───────────────────────────────────────────────────────\n\n/**\n * width/height are the drawn box at scale 1. Every logo's visible ink is\n * exactly 40 tall: boxes taller than 40 compensate for empty padding baked\n * into that SVG's viewBox (StarSling 15%, The Hog 8%), so on-screen heights\n * stay uniform as slots cycle. Widths follow each viewBox aspect ratio.\n */\nconst LOGOS: LogoDef[] = [\n  { name: \"Sim\", src: \"/brands/sim.svg\", url: \"https://sim.ai\", width: 82, height: 40 },\n  { name: \"AgentMail\", src: \"/brands/agentmail.svg\", url: \"https://agentmail.to\", width: 219, height: 40 },\n  { name: \"AgentPhone\", src: \"/brands/agentphone.svg\", url: \"https://agentphone.ai\", width: 212, height: 40 },\n  { name: \"Orchid\", src: \"/brands/orchid.svg\", url: \"https://orchid.ai\", width: 166, height: 40 },\n  { name: \"StarSling\", src: \"/brands/starsling.svg\", url: \"https://starsling.dev\", width: 191, height: 47 },\n  { name: \"The Hog\", src: \"/brands/thehog.svg\", url: \"https://thehog.ai\", width: 199, height: 43 },\n  { name: \"Feyn\", src: \"/brands/feyn.svg\", url: \"https://usefeyn.com\", width: 95, height: 40 },\n  { name: \"Bloom\", src: \"/brands/bloom.svg\", url: \"https://trybloom.ai\", width: 147, height: 40 },\n  { name: \"& more\", url: \"/works\", width: 96, height: 40 },\n];\n\n// ── Constants ───────────────────────────────────────────────────────\n\nconst LOGO_SCALE = 0.8;\nconst SLOT_WIDTH = 240;\nconst MAX_LOGO_HEIGHT = Math.max(...LOGOS.map((l) => l.height));\nconst INITIAL_DELAY = 2500;\nconst SLOT_STAGGER = 150;\nconst CYCLE_INTERVAL = 3000;\n\nconst LOGO_SRCS = LOGOS.flatMap((l) => (l.src ? [l.src] : []));\n\n// ── Hooks ───────────────────────────────────────────────────────────\n\n/** Returns responsive slot count: 1 on mobile, 2 on tablet, 3 on desktop. */\nfunction useSlotCount(): number {\n  const [count, setCount] = useState(3);\n\n  useEffect(() => {\n    const mqMd = window.matchMedia(\"(min-width: 768px)\");\n    const mqLg = window.matchMedia(\"(min-width: 1024px)\");\n\n    const update = () => {\n      if (mqLg.matches) setCount(3);\n      else if (mqMd.matches) setCount(2);\n      else setCount(1);\n    };\n\n    update();\n    mqMd.addEventListener(\"change\", update);\n    mqLg.addEventListener(\"change\", update);\n\n    return () => {\n      mqMd.removeEventListener(\"change\", update);\n      mqLg.removeEventListener(\"change\", update);\n    };\n  }, []);\n\n  return count;\n}\n\n/** Resolves `true` once every image in `srcs` has loaded (or errored). */\nfunction useImagesPreloaded(srcs: readonly string[]): boolean {\n  const [loaded, setLoaded] = useState(false);\n\n  useEffect(() => {\n    let cancelled = false;\n\n    Promise.all(\n      srcs.map(\n        (src) =>\n          new Promise<void>((resolve) => {\n            const img = new window.Image();\n            img.onload = () => resolve();\n            img.onerror = () => resolve();\n            img.src = src;\n          }),\n      ),\n    ).then(() => {\n      if (!cancelled) setLoaded(true);\n    });\n\n    return () => {\n      cancelled = true;\n    };\n  }, [srcs]);\n\n  return loaded;\n}\n\n/**\n * Cycles through a list of logos.\n * Pauses when the tab is hidden so staggered delays stay in sync on return.\n */\nfunction useLogoCycle(\n  logos: LogoDef[],\n  initialDelay: number,\n  enabled: boolean,\n) {\n  const [step, setStep] = useState(0);\n  const current = logos[step % logos.length];\n\n  useEffect(() => {\n    if (!enabled) return;\n\n    let timeoutId: ReturnType<typeof setTimeout> | null = null;\n    let startedAt = 0;\n    let remaining = step === 0 ? initialDelay : CYCLE_INTERVAL;\n\n    const schedule = (delay: number) => {\n      remaining = delay;\n      startedAt = Date.now();\n      timeoutId = setTimeout(() => setStep((s) => s + 1), delay);\n    };\n\n    const pause = () => {\n      if (timeoutId != null) {\n        clearTimeout(timeoutId);\n        timeoutId = null;\n        remaining = Math.max(0, remaining - (Date.now() - startedAt));\n      }\n    };\n\n    const onVisibilityChange = () => {\n      if (document.hidden) pause();\n      else schedule(remaining);\n    };\n\n    document.addEventListener(\"visibilitychange\", onVisibilityChange);\n    if (!document.hidden) schedule(remaining);\n\n    return () => {\n      if (timeoutId != null) clearTimeout(timeoutId);\n      document.removeEventListener(\"visibilitychange\", onVisibilityChange);\n    };\n  }, [enabled, step, initialDelay]);\n\n  return { current, hasCycled: step > 0 };\n}\n\n// ── LogoSlot ────────────────────────────────────────────────────────\n\ntype CarouselVariant = \"muted\" | \"dark\" | \"light\";\n\nconst variantStyles: Record<CarouselVariant, { base: string; interactive: string }> = {\n  muted: {\n    base: \"brightness-0 opacity-40 dark:invert\",\n    interactive: \"transition-opacity duration-200 hover:opacity-60\",\n  },\n  dark: {\n    base: \"brightness-0 dark:invert\",\n    interactive: \"transition-opacity duration-200 opacity-80 hover:opacity-100\",\n  },\n  /* White logos for blue/dark banners */\n  light: {\n    base: \"brightness-0 invert opacity-55\",\n    interactive: \"transition-opacity duration-200 hover:opacity-90\",\n  },\n};\n\n/* Text slides (\"& more\") mirror each variant's filtered-image color. */\nconst textVariantStyles: Record<CarouselVariant, { base: string; interactive: string }> = {\n  muted: {\n    base: \"text-black opacity-40 dark:text-white\",\n    interactive: \"transition-opacity duration-200 hover:opacity-60\",\n  },\n  dark: {\n    base: \"text-black dark:text-white\",\n    interactive: \"transition-opacity duration-200 opacity-80 hover:opacity-100\",\n  },\n  light: {\n    base: \"text-white opacity-55\",\n    interactive: \"transition-opacity duration-200 hover:opacity-90\",\n  },\n};\n\nfunction LogoSlot({\n  logos,\n  slotIndex,\n  enabled,\n  disableLinks,\n  variant = \"muted\",\n  slotWidth = SLOT_WIDTH,\n  slotShare,\n  logoScale = LOGO_SCALE,\n}: {\n  logos: LogoDef[];\n  slotIndex: number;\n  enabled: boolean;\n  disableLinks?: boolean;\n  variant?: CarouselVariant;\n  slotWidth?: number | \"fit\";\n  /** Fluid mode: this slot's fixed share of the container width (0–1). */\n  slotShare?: number;\n  logoScale?: number;\n}) {\n  const reducedMotion = useReducedMotion();\n  // \"fit\" sizes the slot to its own widest logo, so idle air stays minimal\n  // while the width still never changes as logos cycle.\n  const resolvedSlotWidth =\n    slotWidth === \"fit\"\n      ? Math.round(Math.max(...logos.map((l) => l.width)) * logoScale)\n      : slotWidth;\n  const { current: logo, hasCycled } = useLogoCycle(\n    logos,\n    INITIAL_DELAY + slotIndex * SLOT_STAGGER,\n    enabled,\n  );\n\n  const styles = variantStyles[variant];\n  const textStyles = textVariantStyles[variant];\n  // Fluid slots size every logo as a fraction of the slot width (cqw), so\n  // when the container squeezes the slot, all logos shrink by one shared\n  // factor — a per-logo max-w clamp would shrink only the widest and break\n  // the uniform ink height.\n  const fluid = slotShare != null;\n  const widestWidth = Math.max(...logos.map((l) => l.width));\n  const TEXT_SIZE = 24; // def-space font size for text slides (\"& more\")\n  const imgEl = logo.src ? (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      src={logo.src}\n      alt={disableLinks ? logo.name : \"\"}\n      width={Math.round(logo.width * logoScale)}\n      height={Math.round(logo.height * logoScale)}\n      className={cn(\"h-auto max-w-full\", styles.base, !disableLinks && styles.interactive)}\n      style={fluid ? { width: `${((logo.width / widestWidth) * 100).toFixed(2)}cqw` } : undefined}\n    />\n  ) : (\n    <span\n      className={cn(\n        \"whitespace-nowrap font-normal leading-none tracking-[-0.01em]\",\n        textStyles.base,\n        !disableLinks && textStyles.interactive,\n      )}\n      style={{\n        fontSize: fluid\n          ? `${((TEXT_SIZE / widestWidth) * 100).toFixed(2)}cqw`\n          : Math.round(TEXT_SIZE * logoScale),\n      }}\n    >\n      {logo.name}\n    </span>\n  );\n\n  // A share-sized slot is a fixed percentage of the container, so its width\n  // depends only on the viewport, never on which logo is currently shown.\n  // 88% is distributable; the remaining 12% becomes gaps via justify-between.\n  const sizing =\n    slotShare != null\n      ? { width: `${(slotShare * 88).toFixed(2)}%`, flex: \"0 0 auto\" }\n      : { flex: `0 1 ${resolvedSlotWidth}px` };\n\n  return (\n    <div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={logo.name}\n      className={cn(\n        \"overflow-hidden flex items-center justify-center\",\n        fluid && \"[container-type:inline-size]\",\n      )}\n      style={{\n        ...sizing,\n        minWidth: 0,\n        height: MAX_LOGO_HEIGHT * logoScale + 40,\n        marginBlock: -20,\n      }}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.div\n          key={logo.name}\n          initial={\n            !hasCycled\n              ? false\n              : reducedMotion\n                ? { opacity: 0 }\n                : { y: 20, opacity: 0, filter: \"blur(8px)\" }\n          }\n          animate={\n            reducedMotion\n              ? { opacity: 1 }\n              : { y: 0, opacity: 1, filter: \"blur(0px)\" }\n          }\n          exit={\n            reducedMotion\n              ? { opacity: 0 }\n              : { y: -20, opacity: 0, filter: \"blur(8px)\" }\n          }\n          transition={{ duration: 0.5, ease: \"easeInOut\" }}\n          className=\"flex max-w-full items-center justify-center will-change-[filter] backface-hidden\"\n        >\n          {disableLinks ? (\n            imgEl\n          ) : logo.src ? (\n            <Link\n              href={`${logo.url}?ref=arc`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              aria-label={`${logo.name} (opens in new tab)`}\n              className=\"flex max-w-full\"\n            >\n              {imgEl}\n            </Link>\n          ) : (\n            <Link\n              href={logo.url}\n              aria-label={`${logo.name} — view all works`}\n              className=\"flex max-w-full\"\n            >\n              {imgEl}\n            </Link>\n          )}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\n// ── LogoCarousel ────────────────────────────────────────────────────\n\nexport function LogoCarousel({\n  className,\n  disableLinks,\n  variant = \"muted\",\n  slotWidth,\n  maxSlots,\n  slots,\n  logoScale,\n}: {\n  className?: string;\n  disableLinks?: boolean;\n  variant?: CarouselVariant;\n  /**\n   * Slot sizing: a fixed pixel width, \"fit\" (each slot hugs its widest logo),\n   * or \"fluid\" (each slot is a fixed percentage of the container, sized\n   * proportionally to its widest logo; widths never move during transitions).\n   */\n  slotWidth?: number | \"fit\" | \"fluid\";\n  maxSlots?: number;\n  /** Fixed slot count on every breakpoint (overrides responsive counting). */\n  slots?: number;\n  logoScale?: number;\n}) {\n  const allLoaded = useImagesPreloaded(LOGO_SRCS);\n  const responsiveSlots = useSlotCount();\n  // Never more slots than logos; an empty slot has nothing to render.\n  const slotCount = Math.min(\n    LOGOS.length,\n    slots ?? (maxSlots ? Math.min(responsiveSlots, maxSlots) : responsiveSlots),\n  );\n\n  const slotLogos = useMemo(\n    () =>\n      Array.from({ length: slotCount }, (_, slot) =>\n        LOGOS.filter((_, i) => i % slotCount === slot),\n      ),\n    [slotCount],\n  );\n\n  // Fluid mode: each slot's share of the row is proportional to the widest\n  // logo it will ever show, so no rotation can outgrow its box.\n  const fluid = slotWidth === \"fluid\";\n  const slotShares = useMemo(() => {\n    if (!fluid) return null;\n    const widest = slotLogos.map((logos) =>\n      Math.max(...logos.map((l) => l.width)),\n    );\n    const total = widest.reduce((sum, w) => sum + w, 0);\n    return widest.map((w) => w / total);\n  }, [fluid, slotLogos]);\n\n  return (\n    <motion.div\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label=\"Companies we've partnered with\"\n      initial={{ opacity: 0 }}\n      animate={{ opacity: allLoaded ? 1 : 0 }}\n      transition={{ duration: 0.4, ease: \"easeOut\" }}\n      className={cn(\n        \"flex items-center\",\n        fluid ? \"w-full justify-between\" : \"gap-4\",\n        className,\n      )}\n    >\n      {slotLogos.map((logos, i) => (\n        <LogoSlot\n          key={i}\n          logos={logos}\n          slotIndex={i}\n          enabled={allLoaded}\n          disableLinks={disableLinks}\n          variant={variant}\n          slotWidth={fluid ? \"fit\" : slotWidth}\n          slotShare={slotShares?.[i]}\n          logoScale={logoScale}\n        />\n      ))}\n    </motion.div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/ui/logo-carousel.tsx"
    }
  ],
  "type": "registry:component"
}