{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kinetic-logo-exchange",
  "type": "registry:ui",
  "title": "Kinetic logo exchange",
  "description": "Reorders and reveals brand marks in a kinetic queue that yields to user selection.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "visual-ambient"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/kinetic-logo-exchange/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Brand queue with layout exchange and masked reveal",
    "sceneFamily": "spatial-dark",
    "motionRole": "ambient",
    "primaryState": "Kinetic brand queue settled on its selection",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/kinetic-logo-exchange.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/kinetic-logo-exchange.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nexport type KineticLogoItem = {\n  id: string;\n  label: string;\n  mark?: ReactNode;\n  tone?: \"blue\" | \"clay\" | \"moss\" | \"ink\";\n};\n\nexport type KineticLogoExchangeProps = {\n  items: readonly KineticLogoItem[];\n  label?: string;\n  eyebrow?: string;\n  pauseLabel?: string;\n  resumeLabel?: string;\n  emptyLabel?: string;\n  interval?: number;\n  columns?: 2 | 3 | 4;\n  className?: string;\n  onSelect?: (item: KineticLogoItem) => void;\n};\n\nconst toneClass = {\n  blue: \"bg-[#dce9ff] text-[#174496] dark:bg-[#1c3d82] dark:text-[#e3edff]\",\n  clay: \"bg-[#ffe4cb] text-[#85410e] dark:bg-[#6b3517] dark:text-[#ffe8d2]\",\n  moss: \"bg-[#d9f0df] text-[#1d6741] dark:bg-[#1b5334] dark:text-[#ddf6e6]\",\n  ink: \"bg-neutral-950 text-white dark:bg-neutral-100 dark:text-neutral-950\",\n} as const;\n\nfunction rotate<T>(values: readonly T[]) {\n  if (values.length < 2) return [...values];\n  return [...values.slice(1), values[0]];\n}\n\nexport function KineticLogoExchange({\n  items,\n  label = \"Works with your tools\",\n  eyebrow = \"Connections\",\n  pauseLabel = \"Pause logo exchange\",\n  resumeLabel = \"Resume logo exchange\",\n  emptyLabel = \"No connected tools\",\n  interval = 3600,\n  columns = 3,\n  className = \"\",\n  onSelect,\n}: KineticLogoExchangeProps) {\n  const reduced = useReducedMotion() === true;\n  const cadence = Number.isFinite(interval) ? Math.max(1800, interval) : 3600;\n  const [order, setOrder] = useState(() => items.map((item) => item.id));\n  const [paused, setPaused] = useState(false);\n  const [pointerPaused, setPointerPaused] = useState(false);\n  const [focusPaused, setFocusPaused] = useState(false);\n  const [selected, setSelected] = useState(items[0]?.id ?? \"\");\n  const [cycle, setCycle] = useState(0);\n  const root = useRef<HTMLElement>(null);\n  const [visible, setVisible] = useState(true);\n  const incomingIds = useMemo(() => items.map((item) => item.id), [items]);\n  const incomingIdSequence = JSON.stringify(incomingIds);\n  const syncedIdSequence = useRef(incomingIdSequence);\n  const byId = useMemo(\n    () => new Map(items.map((item) => [item.id, item])),\n    [items],\n  );\n\n  useEffect(() => {\n    if (syncedIdSequence.current === incomingIdSequence) return;\n    syncedIdSequence.current = incomingIdSequence;\n    setOrder(incomingIds);\n    setSelected((current) => current && incomingIds.includes(current) ? current : incomingIds[0] ?? \"\");\n  }, [incomingIdSequence, incomingIds]);\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") return;\n    let intersecting = true;\n    let documentVisible = !document.hidden;\n    const update = () => setVisible(intersecting && documentVisible);\n    const observer = new IntersectionObserver(([entry]) => {\n      intersecting = entry.isIntersecting;\n      update();\n    });\n    const onVisibility = () => {\n      documentVisible = !document.hidden;\n      update();\n    };\n    observer.observe(node);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (reduced || paused || pointerPaused || focusPaused || !visible || order.length < 2) return;\n    const timer = window.setInterval(() => {\n      setOrder((current) => rotate(current));\n      setCycle((current) => current + 1);\n    }, cadence);\n    return () => window.clearInterval(timer);\n  }, [cadence, focusPaused, order.length, paused, pointerPaused, reduced, visible]);\n\n  const pause = () => setPaused(true);\n\n  return (\n    <section\n      ref={root}\n      aria-label={label}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") setPointerPaused(true);\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") setPointerPaused(false);\n      }}\n      onFocusCapture={() => setFocusPaused(true)}\n      onBlurCapture={(event) => {\n        const next = event.relatedTarget as Node | null;\n        if (next && event.currentTarget.contains(next)) return;\n        setFocusPaused(false);\n      }}\n      className={`w-full overflow-hidden rounded-[10px] border border-neutral-200 bg-[#f5f5f5] p-3 dark:border-white/[0.14] dark:bg-[#181818] ${className}`}\n    >\n      <header className=\"mb-2 flex min-h-11 items-center justify-between gap-3 px-1\">\n        <div className=\"min-w-0\">\n          <span className=\"block font-mono text-[9px] text-neutral-500 dark:text-neutral-400\">{eyebrow}</span>\n          <h3 className=\"mt-0.5 text-[13px] font-medium tracking-[-0.015em] text-[#292929] dark:text-neutral-100\">{label}</h3>\n        </div>\n        {reduced || items.length < 2 ? null : (\n          <button\n            type=\"button\"\n            aria-label={paused ? resumeLabel : pauseLabel}\n            aria-pressed={paused}\n            onClick={() => setPaused((value) => !value)}\n            className=\"grid size-11 place-items-center rounded-lg border border-black/[0.08] bg-white text-[#292929] outline-none transition-transform duration-150 active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 dark:border-white/[0.12] dark:bg-[#202020] dark:text-white\"\n          >\n            {paused ? (\n              <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden>\n                <path d=\"m7.3 5.4 7 4.6-7 4.6V5.4Z\" fill=\"currentColor\" />\n              </svg>\n            ) : (\n              <svg viewBox=\"0 0 20 20\" fill=\"none\" className=\"size-4\" aria-hidden>\n                <path d=\"M6.8 5.2v9.6M13.2 5.2v9.6\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n              </svg>\n            )}\n          </button>\n        )}\n      </header>\n\n      {items.length === 0 ? (\n        <div role=\"status\" className=\"grid min-h-[72px] place-items-center rounded-lg border border-black/[0.07] bg-white px-4 text-center text-[12px] text-neutral-600 dark:border-white/[0.09] dark:bg-[#202020] dark:text-neutral-300\">\n          {emptyLabel}\n        </div>\n      ) : <div\n        className=\"grid gap-1.5\"\n        style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}\n      >\n        {order.map((id, index) => {\n          const item = byId.get(id);\n          if (!item) return null;\n          const active = selected === item.id;\n          return (\n            <motion.button\n              layout={!reduced}\n              key={item.id}\n              data-kinetic-logo-item={item.id}\n              type=\"button\"\n              aria-pressed={active}\n              onClick={() => {\n                pause();\n                setSelected(item.id);\n                onSelect?.(item);\n              }}\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { type: \"spring\", stiffness: 430, damping: 38, mass: 0.52 }\n              }\n              className={`group relative min-h-[72px] min-w-0 overflow-hidden rounded-lg border bg-white p-2 text-left outline-none transition-[border-color,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-1 dark:bg-[#202020] ${\n                active\n                  ? \"border-neutral-950 dark:border-neutral-50\"\n                  : \"border-black/[0.07] dark:border-white/[0.09]\"\n              }`}\n            >\n              <motion.span\n                key={`${item.id}-${cycle}`}\n                initial={\n                  reduced\n                    ? false\n                    : {\n                        opacity: 0.48,\n                        filter: \"blur(5px)\",\n                        clipPath: index % 2 === 0 ? \"inset(0 0 100% 0)\" : \"inset(100% 0 0 0)\",\n                      }\n                }\n                animate={{ opacity: 1, filter: \"blur(0px)\", clipPath: \"inset(0 0 0% 0)\" }}\n                transition={{ duration: reduced ? 0 : 0.24, ease: [0.2, 0.8, 0.2, 1] }}\n                className=\"flex min-w-0 items-center gap-2\"\n              >\n                <span\n                  className={`grid size-9 shrink-0 place-items-center rounded-lg ${toneClass[item.tone ?? \"ink\"]}`}\n                >\n                  {item.mark ?? (\n                    <span className=\"text-[11px] font-semibold uppercase\">{item.label.slice(0, 2)}</span>\n                  )}\n                </span>\n                <span className=\"min-w-0 truncate text-[10.5px] font-medium text-neutral-600 dark:text-neutral-300\">{item.label}</span>\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </div>}\n    </section>\n  );\n}\n"
    }
  ]
}
