{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wipe-transition",
  "title": "Wipe Transition",
  "description": "Switches content behind a band that sweeps across it, swapping while covered, with a crossfade for phones and reduced motion.",
  "registryDependencies": [
    "@corsair-ui/utils",
    "@corsair-ui/use-media-query"
  ],
  "files": [
    {
      "path": "registry/default/ui/wipe-transition.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\n\nimport { useMediaQuery } from \"@/registry/default/hooks/use-media-query\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype WipeDirection = \"left\" | \"right\" | \"up\" | \"down\";\ntype WipeMode = \"wipe\" | \"fade\";\ntype WipePhase = \"covering\" | \"revealing\";\n\n/** Where the cover starts (before it covers) and where it leaves to, for each way it travels. */\nconst OFFSETS: Record<WipeDirection, readonly [enter: string, leave: string]> = {\n  right: [\"-100%\", \"100%\"],\n  left: [\"100%\", \"-100%\"],\n  down: [\"-100%\", \"100%\"],\n  up: [\"100%\", \"-100%\"],\n};\n\nconst COVERED = \"translate3d(0, 0, 0)\";\n\nfunction coverTransform(direction: WipeDirection, offset: string) {\n  return direction === \"left\" || direction === \"right\"\n    ? `translate3d(${offset}, 0, 0)`\n    : `translate3d(0, ${offset}, 0)`;\n}\n\n/**\n * Runs one half of the transition on `element` with the Web Animations API\n * and resolves when it is done. Without `element.animate` (old browsers,\n * jsdom) or with no time to spend, it resolves on the next microtask, so the\n * swap still happens, only at once.\n */\nfunction play(\n  element: HTMLElement | null,\n  keyframes: Keyframe[],\n  duration: number,\n  easing: string\n): { finished: Promise<unknown>; cancel: () => void } {\n  if (!element || duration <= 0 || typeof element.animate !== \"function\") {\n    return { finished: Promise.resolve(), cancel: () => {} };\n  }\n  // Held at the end until the next phase's inline style has taken over, then cancelled.\n  const animation = element.animate(keyframes, { duration, easing, fill: \"forwards\" });\n  return { finished: animation.finished, cancel: () => animation.cancel() };\n}\n\ninterface WipeRun {\n  phase: WipePhase;\n  mode: WipeMode;\n  direction: WipeDirection;\n  /** Length of each half, in ms. */\n  half: number;\n  easing: string;\n}\n\ninterface WipeTransitionProps extends ComponentProps<\"div\"> {\n  /**\n   * Identifies what `children` show. When it changes, the cover sweeps in,\n   * the content is swapped while it is covered, and the cover sweeps out.\n   */\n  transitionKey: string | number;\n  /** What sweeps across. It fills the cover layer. Defaults to a `bg-primary` band. */\n  cover?: ReactNode;\n  /** Classes for the cover layer, which is positioned over the content. */\n  coverClassName?: string;\n  /** The way the cover travels: \"right\" sweeps in from the left and leaves on the right. */\n  direction?: WipeDirection;\n  /** Total length in ms: half to cover, half to reveal. */\n  duration?: number;\n  /** Timing function for each half, any CSS easing. */\n  easing?: string;\n  /**\n   * \"wipe\" sweeps the cover across; \"fade\" fades the old content out and the\n   * new one in, with no cover (pick it on phones, for example).\n   */\n  mode?: WipeMode;\n  /** With `prefers-reduced-motion` it always fades, over this many ms in total. 0 swaps at once. */\n  reducedDuration?: number;\n  /**\n   * Cover the viewport (`fixed`) instead of this element, for switching a\n   * whole page that may be taller than the screen.\n   */\n  fixed?: boolean;\n  /**\n   * Called once the content is covered and the new children are in place,\n   * before the cover leaves: a good moment to `window.scrollTo({ top: 0 })`.\n   */\n  onCovered?: () => void;\n  /** Called when the cover has left, or the new content has faded in. */\n  onComplete?: () => void;\n}\n\n/**\n * Switches content behind a band that sweeps across it: \"For players\" to\n * \"For venues\", a tab, a language, a theme. When `transitionKey` changes,\n * the cover travels in, the old children stay on screen until it covers\n * them, the new children are rendered underneath, `onCovered` is called,\n * and the cover travels out. Only `transform` (cover) and `opacity` (fade)\n * animate, through the Web Animations API.\n *\n * While it runs, the root has `aria-busy` and the content ignores the\n * pointer. If keyboard focus was inside the old content when it went away,\n * focus moves to the content wrapper instead of dropping to the page.\n * Keys that change during a transition are queued: it always ends on the\n * latest. `mode=\"fade\"` crossfades instead; with `prefers-reduced-motion`\n * it always crossfades, over `reducedDuration`. The first render shows the\n * children as they are, so server HTML needs no JavaScript.\n *\n * @example\n * <WipeTransition\n *   transitionKey={side}\n *   fixed\n *   cover={<div className=\"bg-primary\" />}\n *   onCovered={() => window.scrollTo({ top: 0 })}\n * >\n *   {side === \"players\" ? <PlayersPage /> : <VenuesPage />}\n * </WipeTransition>\n */\nfunction WipeTransition({\n  transitionKey,\n  cover,\n  coverClassName,\n  direction = \"right\",\n  duration = 600,\n  easing = \"cubic-bezier(0.65, 0, 0.35, 1)\",\n  mode = \"wipe\",\n  reducedDuration = 150,\n  fixed = false,\n  onCovered,\n  onComplete,\n  className,\n  children,\n  ref,\n  ...props\n}: WipeTransitionProps) {\n  const reduced = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n\n  // What is on screen: the children of the last key shown. While the key\n  // differs (a transition is under way), these stay as they were.\n  const [shown, setShown] = useState<{ key: string | number; children: ReactNode }>({\n    key: transitionKey,\n    children,\n  });\n  const [run, setRun] = useState<WipeRun | null>(null);\n\n  const current = transitionKey === shown.key;\n  // Same key: keep the snapshot fresh, so a later switch starts from what is on screen now.\n  if (current && children !== shown.children) setShown({ key: transitionKey, children });\n  // A new key and nothing running: start covering. A key that changes\n  // mid-run is picked up at the swap, or by the next run once this one ends.\n  if (!current && !run) {\n    setRun({\n      phase: \"covering\",\n      mode: reduced ? \"fade\" : mode,\n      direction,\n      half: Math.max(0, reduced ? reducedDuration : duration) / 2,\n      easing,\n    });\n  }\n\n  const content = useRef<HTMLDivElement>(null);\n  const coverLayer = useRef<HTMLDivElement>(null);\n  const latest = useRef({ key: transitionKey, children, onCovered, onComplete });\n  const focusWasInside = useRef(false);\n  const finished = useRef(false);\n\n  useIsomorphicLayoutEffect(() => {\n    latest.current = { key: transitionKey, children, onCovered, onComplete };\n  });\n\n  // Called after the run has ended and the next state is committed (idle, or the queued key's run).\n  useIsomorphicLayoutEffect(() => {\n    if (!finished.current) return;\n    finished.current = false;\n    latest.current.onComplete?.();\n  }, [run]);\n\n  // First half: the cover comes in, or the old content fades out. Then swap.\n  useIsomorphicLayoutEffect(() => {\n    if (run?.phase !== \"covering\") return;\n    const { mode, direction, half, easing } = run;\n    const animation =\n      mode === \"fade\"\n        ? play(content.current, [{ opacity: 1 }, { opacity: 0 }], half, easing)\n        : play(\n            coverLayer.current,\n            [\n              { transform: coverTransform(direction, OFFSETS[direction][0]) },\n              { transform: COVERED },\n            ],\n            half,\n            easing\n          );\n    let cancelled = false;\n    animation.finished.then(\n      () => {\n        if (cancelled) return;\n        const active = document.activeElement;\n        focusWasInside.current = !!active && !!content.current?.contains(active);\n        // The latest key and its children, even if the key changed while covering.\n        setShown({ key: latest.current.key, children: latest.current.children });\n        setRun({ ...run, phase: \"revealing\" });\n      },\n      () => {}\n    );\n    return () => {\n      cancelled = true;\n      animation.cancel();\n    };\n  }, [run]);\n\n  // Second half: the new children are committed. Restore focus, report, and reveal.\n  useIsomorphicLayoutEffect(() => {\n    if (run?.phase !== \"revealing\") return;\n    const wrapper = content.current;\n    const active = document.activeElement;\n    if (focusWasInside.current && wrapper && (!active || active === document.body)) {\n      // Focusable only for as long as it holds focus, so it never joins the tab order.\n      wrapper.tabIndex = -1;\n      wrapper.addEventListener(\"blur\", () => wrapper.removeAttribute(\"tabindex\"), { once: true });\n      wrapper.focus({ preventScroll: true });\n    }\n    focusWasInside.current = false;\n    latest.current.onCovered?.();\n\n    const { mode, direction, half, easing } = run;\n    const animation =\n      mode === \"fade\"\n        ? play(wrapper, [{ opacity: 0 }, { opacity: 1 }], half, easing)\n        : play(\n            coverLayer.current,\n            [\n              { transform: COVERED },\n              { transform: coverTransform(direction, OFFSETS[direction][1]) },\n            ],\n            half,\n            easing\n          );\n    let cancelled = false;\n    animation.finished.then(\n      () => {\n        if (cancelled) return;\n        finished.current = true;\n        setRun(null);\n      },\n      () => {}\n    );\n    return () => {\n      cancelled = true;\n      animation.cancel();\n    };\n  }, [run]);\n\n  // Inline resting styles for each phase, so nothing flashes before or between the animations.\n  const coverStyle: CSSProperties | undefined = run\n    ? {\n        transform:\n          run.phase === \"covering\"\n            ? coverTransform(run.direction, OFFSETS[run.direction][0])\n            : COVERED,\n      }\n    : undefined;\n  const contentStyle: CSSProperties | undefined =\n    run?.mode === \"fade\" && run.phase === \"revealing\" ? { opacity: 0 } : undefined;\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"wipe-transition\"\n      data-state={run?.phase ?? \"idle\"}\n      data-mode={run?.mode ?? (reduced ? \"fade\" : mode)}\n      data-direction={direction}\n      aria-busy={run ? true : undefined}\n      className={cn(\n        \"relative\",\n        // Contained: the cover stacks and clips within this element. Fixed: it covers the viewport.\n        !fixed && \"isolate\",\n        // `clip`, not `hidden`: no scroll container, so sticky descendants keep sticking.\n        !fixed && run && \"overflow-clip\",\n        className\n      )}\n      {...props}\n    >\n      <div\n        ref={content}\n        data-slot=\"wipe-transition-content\"\n        className={cn(\"outline-none\", run && \"pointer-events-none select-none\")}\n        style={contentStyle}\n      >\n        {current ? children : shown.children}\n      </div>\n      {run?.mode === \"wipe\" ? (\n        <div\n          ref={coverLayer}\n          data-slot=\"wipe-transition-cover\"\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none inset-0 z-50 grid will-change-transform\",\n            fixed ? \"fixed\" : \"absolute\",\n            coverClassName\n          )}\n          style={coverStyle}\n        >\n          {cover ?? <div className=\"bg-primary\" />}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport { WipeTransition, type WipeTransitionProps };\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}