{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-carousel-swapper",
  "title": "Logo Carousel Swapper",
  "description": "Staggered logo column swapper with fade, vertical shift, and motion blur powered by Motion.",
  "dependencies": ["motion", "class-variance-authority"],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/primitives/effects/logo-carousel-swapper/index.tsx",
      "content": "// biome-ignore-all lint/performance/noImgElement: Motion animates filter, opacity, and y on native img elements.\n// biome-ignore-all lint/correctness/useImageSize: Slot containers use fixed dimensions; logos are object-contain.\n// biome-ignore-all lint/suspicious/noArrayIndexKey: Column slots are fixed-position and never reorder.\n\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentPropsWithoutRef,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\n\nconst DEFAULT_INTERVAL_MS = 3000;\nconst DEFAULT_STAGGER_S = 0.14;\nconst DEFAULT_COLUMNS = 4;\nconst EXIT_DURATION_S = 0.55;\nconst ENTER_DURATION_S = 0.7;\n/** Enter overlaps exit so the incoming logo \"pushes\" the outgoing one. */\nconst ENTER_OVERLAP_S = 0.14;\n/** Opacity-only crossfade when `prefers-reduced-motion: reduce`. */\nconst REDUCED_MOTION_FADE_DURATION_S = 0.35;\n\nconst MOTION_BLUR = \"blur(3px)\";\n\n/** Minimum rendered logo footprint on narrow viewports (72×76px). */\nconst MOBILE_LOGO_SIZE_CLASSES =\n  \"max-sm:h-[76px] max-sm:min-h-[76px] max-sm:w-[72px] max-sm:min-w-[72px] max-sm:max-h-[76px] max-sm:max-w-[72px]\";\n\ntype LogoCarouselSize = \"default\" | \"lg\" | \"sm\";\n\n/** Vertical travel per size — kept within slot height when combined with overflow clipping. */\nconst MOTION_OFFSET_Y = {\n  default: { enter: 18, exit: -18 },\n  lg: { enter: 22, exit: -22 },\n  sm: { enter: 14, exit: -14 },\n} as const satisfies Record<LogoCarouselSize, { enter: number; exit: number }>;\n\nfunction motionOffsetForSize(\n  size: VariantProps<typeof logoCarouselSwapperSlotVariants>[\"size\"]\n) {\n  return MOTION_OFFSET_Y[(size ?? \"default\") as LogoCarouselSize];\n}\n\nfunction enterFrom(offsetY: number) {\n  return {\n    filter: MOTION_BLUR,\n    opacity: 0,\n    y: offsetY,\n    z: 0,\n  } as const;\n}\n\nconst ENTER_TO = {\n  filter: \"blur(0px)\",\n  opacity: 1,\n  y: 0,\n  z: 0,\n} as const;\n\nfunction exitTo(offsetY: number) {\n  return {\n    filter: MOTION_BLUR,\n    opacity: 0,\n    y: offsetY,\n    z: 0,\n  } as const;\n}\n\n/** GSAP power3.in — accelerates out quickly. */\nconst EXIT_EASE = [0.55, 0.055, 0.675, 0.19] as const;\n/** GSAP power4.out — snappy approach, soft landing. */\nconst ENTER_EASE = [0.165, 0.84, 0.44, 1] as const;\n\nconst logoCarouselSwapperVariants = cva(\"w-full max-w-full overflow-hidden\", {\n  variants: {\n    align: {\n      center: \"\",\n      end: \"\",\n      start: \"\",\n    },\n    size: {\n      default: \"\",\n      lg: \"\",\n      sm: \"\",\n    },\n  },\n  defaultVariants: {\n    align: \"center\",\n    size: \"default\",\n  },\n});\n\nconst logoCarouselSwapperRowVariants = cva(\n  [\n    \"grid w-full min-w-0 max-w-full grid-cols-2 items-center overflow-hidden py-4\",\n    \"sm:flex sm:flex-row sm:py-6 md:py-8\",\n  ].join(\" \"),\n  {\n    variants: {\n      align: {\n        center: \"justify-items-center sm:justify-center\",\n        end: \"justify-items-end sm:justify-end\",\n        start: \"justify-items-start sm:justify-start\",\n      },\n      size: {\n        default: \"gap-3 sm:gap-6 md:gap-10\",\n        lg: \"gap-3 sm:gap-8 md:gap-12\",\n        sm: \"gap-2 sm:gap-4 md:gap-6\",\n      },\n    },\n    defaultVariants: {\n      align: \"center\",\n      size: \"default\",\n    },\n  }\n);\n\nconst logoCarouselSwapperSlotVariants = cva(\n  [\n    \"relative isolate grid place-items-center overflow-hidden\",\n    \"max-sm:h-[76px] max-sm:min-h-[76px] max-sm:w-full\",\n    \"sm:min-w-0 sm:flex-1 sm:basis-0\",\n  ].join(\" \"),\n  {\n    variants: {\n      size: {\n        default: \"sm:h-14 md:h-16\",\n        lg: \"sm:h-16 md:h-20\",\n        sm: \"sm:h-12 md:h-14\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n    },\n  }\n);\n\nconst logoCarouselSwapperMotionLayerVariants = cva(\n  \"absolute inset-0 grid place-items-center will-change-[transform,opacity,filter] [transform:translateZ(0)]\"\n);\n\nconst logoCarouselSwapperImageVariants = cva(\n  [\n    \"pointer-events-none select-none object-contain\",\n    MOBILE_LOGO_SIZE_CLASSES,\n    \"sm:max-h-[70%] sm:w-auto sm:max-w-[80%]\",\n  ].join(\" \"),\n  {\n    variants: {\n      monochrome: {\n        false: \"\",\n        true: \"brightness-0 dark:invert\",\n      },\n      size: {\n        default: \"sm:max-h-8 md:max-h-10\",\n        lg: \"sm:max-h-10 md:max-h-12\",\n        sm: \"sm:max-h-7 md:max-h-8\",\n      },\n    },\n    defaultVariants: {\n      monochrome: false,\n      size: \"default\",\n    },\n  }\n);\n\nexport interface LogoCarouselSwapperItem {\n  alt: string;\n  src: string;\n}\n\nexport type LogoCarouselSwapperRow = LogoCarouselSwapperItem[];\n\nexport interface LogoCarouselSwapperProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\">,\n    VariantProps<typeof logoCarouselSwapperVariants> {\n  columns?: number;\n  interval?: number;\n  monochrome?: boolean;\n  rows: LogoCarouselSwapperRow[];\n  stagger?: number;\n}\n\nfunction normalizeRows(\n  rows: LogoCarouselSwapperRow[],\n  columns: number\n): LogoCarouselSwapperRow[] {\n  return rows\n    .map((row) => {\n      const next = row.slice(0, columns);\n\n      while (next.length < columns) {\n        const fallback = row[next.length % Math.max(row.length, 1)];\n        if (!fallback) {\n          break;\n        }\n        next.push(fallback);\n      }\n\n      return next;\n    })\n    .filter((row) => row.length === columns && row.every((item) => item.src));\n}\n\ninterface LogoSlotProps {\n  columnIndex: number;\n  item: LogoCarouselSwapperItem;\n  monochrome: boolean;\n  reduceMotion: boolean | null;\n  rowKey: number;\n  size: VariantProps<typeof logoCarouselSwapperSlotVariants>[\"size\"];\n  stagger: number;\n}\n\nfunction LogoSlot({\n  columnIndex,\n  item,\n  monochrome,\n  reduceMotion,\n  rowKey,\n  size,\n  stagger,\n}: LogoSlotProps) {\n  const exitDelay = columnIndex * stagger;\n  const enterDelay = exitDelay + EXIT_DURATION_S - ENTER_OVERLAP_S;\n  const { enter: enterOffsetY, exit: exitOffsetY } = motionOffsetForSize(size);\n\n  if (reduceMotion) {\n    return (\n      <div className={logoCarouselSwapperSlotVariants({ size })} data-logo-slot>\n        <img\n          alt={item.alt}\n          className={logoCarouselSwapperImageVariants({ monochrome, size })}\n          draggable={false}\n          src={item.src}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className={logoCarouselSwapperSlotVariants({ size })} data-logo-slot>\n      <AnimatePresence initial={false} mode=\"sync\">\n        <motion.div\n          animate={{\n            ...ENTER_TO,\n            transition: {\n              delay: enterDelay,\n              duration: ENTER_DURATION_S,\n              ease: ENTER_EASE,\n            },\n          }}\n          className={logoCarouselSwapperMotionLayerVariants()}\n          exit={{\n            ...exitTo(exitOffsetY),\n            transition: {\n              delay: exitDelay,\n              duration: EXIT_DURATION_S,\n              ease: EXIT_EASE,\n            },\n          }}\n          initial={enterFrom(enterOffsetY)}\n          key={`${rowKey}-${item.src}`}\n        >\n          <img\n            alt={item.alt}\n            className={logoCarouselSwapperImageVariants({ monochrome, size })}\n            draggable={false}\n            src={item.src}\n          />\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport function LogoCarouselSwapper({\n  align,\n  className,\n  columns = DEFAULT_COLUMNS,\n  interval = DEFAULT_INTERVAL_MS,\n  monochrome = false,\n  rows,\n  size,\n  stagger = DEFAULT_STAGGER_S,\n  ...props\n}: LogoCarouselSwapperProps) {\n  const [rowIndex, setRowIndex] = useState(0);\n  const reduceMotion = useReducedMotion();\n\n  const normalizedRows = useMemo(\n    () => normalizeRows(rows, columns),\n    [columns, rows]\n  );\n\n  const activeRow = normalizedRows[rowIndex] ?? [];\n\n  useEffect(() => {\n    if (normalizedRows.length < 2) {\n      return;\n    }\n\n    const intervalId = window.setInterval(() => {\n      setRowIndex((current) => (current + 1) % normalizedRows.length);\n    }, interval);\n\n    return () => {\n      window.clearInterval(intervalId);\n    };\n  }, [interval, normalizedRows.length]);\n\n  if (activeRow.length === 0) {\n    return null;\n  }\n\n  const logoSlots = activeRow.map((item, columnIndex) => (\n    <LogoSlot\n      columnIndex={columnIndex}\n      item={item}\n      key={`logo-slot-${columnIndex}`}\n      monochrome={monochrome}\n      reduceMotion={reduceMotion}\n      rowKey={rowIndex}\n      size={size}\n      stagger={stagger}\n    />\n  ));\n\n  if (reduceMotion) {\n    return (\n      <div\n        className={cn(logoCarouselSwapperVariants({ align, size }), className)}\n        {...props}\n      >\n        <div className=\"grid *:col-start-1 *:row-start-1\">\n          <AnimatePresence initial={false} mode=\"sync\">\n            <motion.div\n              animate={{ opacity: 1 }}\n              aria-hidden={props[\"aria-label\"] ? undefined : true}\n              className={logoCarouselSwapperRowVariants({ align, size })}\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key={rowIndex}\n              transition={{\n                duration: REDUCED_MOTION_FADE_DURATION_S,\n                ease: \"easeInOut\",\n              }}\n            >\n              {logoSlots}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(logoCarouselSwapperVariants({ align, size }), className)}\n      {...props}\n    >\n      <div\n        aria-hidden={props[\"aria-label\"] ? undefined : true}\n        className={logoCarouselSwapperRowVariants({ align, size })}\n      >\n        {logoSlots}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/effects/logo-carousel-swapper.tsx"
    }
  ],
  "meta": {
    "keywords": [
      "logo",
      "carousel",
      "stagger",
      "blur",
      "motion",
      "brands",
      "swap"
    ],
    "demoProps": {
      "LogoCarouselSwapper": {
        "monochrome": {
          "value": true
        }
      }
    }
  },
  "type": "registry:ui"
}
