{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "presence-swap",
  "type": "registry:ui",
  "title": "Presence Swap",
  "description": "Swaps views the way you're going, follows their height and keeps focus.",
  "categories": [
    "motion"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/animate-height.json"
  ],
  "files": [
    {
      "path": "components/ui/presence-swap.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion, type Transition, type Variants } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimateHeight } from \"@/components/ui/animate-height\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Key = string | number;\ntype Direction = 1 | -1;\n\nexport type PresenceSwapProps<K extends Key> = Omit<React.ComponentProps<\"div\">, \"children\" | \"style\"> & {\n  /** Which view is showing. Changing it swaps the children for the new ones. */\n  value: K;\n  /** The view for the current value. */\n  children: React.ReactNode;\n  /**\n   * slide: the new view arrives from the side it lives on (forward from the right),\n   * for steps, tabs and drill-downs. fade: a crossfade in place, for views with no order.\n   */\n  variant?: \"slide\" | \"fade\";\n  /** Every value in order, so it knows which way is forward. Numbers compare on their own. */\n  order?: readonly K[];\n  /** Force the direction of this swap instead of inferring it. */\n  direction?: Direction;\n  /** How far a sliding view travels, in px. */\n  distance?: number;\n  /** Follow the height of each view on a spring. Off, the box jumps to the new height. */\n  animateHeight?: boolean;\n  /** Swap without animating while true, e.g. when the change came from an arrow key. */\n  instant?: boolean;\n  style?: Omit<React.CSSProperties, \"height\">;\n};\n\nfunction directionOf<K extends Key>(from: K, to: K, order?: readonly K[]): Direction {\n  if (order) {\n    const a = order.indexOf(from);\n    const b = order.indexOf(to);\n    if (a >= 0 && b >= 0) return b >= a ? 1 : -1;\n  }\n  if (typeof from === \"number\" && typeof to === \"number\") return to >= from ? 1 : -1;\n  return 1;\n}\n\ntype Custom = { dir: Direction; distance: number };\n\n// Forward arrives from the right and leaves to the left; back is the mirror.\n// The leaving view travels half as far and drops its opacity up front (ease-out,\n// not ease-in), so the two views overlap for as little time as possible and\n// never read as a double exposure.\nconst variants: Record<\"slide\" | \"fade\", Variants> = {\n  slide: {\n    enter: ({ dir, distance }: Custom) => ({ opacity: 0, x: dir * distance, filter: \"blur(2px)\" }),\n    center: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { duration: 0.32, ease: ease.out } },\n    exit: ({ dir, distance }: Custom) => ({\n      opacity: 0,\n      x: dir * -distance * 0.5,\n      filter: \"blur(2px)\",\n      transition: { duration: 0.18, ease: ease.outQuart },\n    }),\n  },\n  fade: {\n    enter: { opacity: 0, filter: \"blur(2px)\" },\n    center: { opacity: 1, filter: \"blur(0px)\", transition: { duration: 0.24, ease: ease.out } },\n    exit: { opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.outQuart } },\n  },\n};\n\n// Reduced motion keeps a short crossfade. Resting styles match the full variants,\n// so server and client agree whatever the setting.\nconst reduced: Variants = {\n  enter: { opacity: 0, x: 0, filter: \"blur(0px)\" },\n  center: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n// Keyboard-driven or bulk changes: the new view is simply there.\nconst none: Transition = { duration: 0 };\nconst immediate: Variants = {\n  enter: { opacity: 0, x: 0, filter: \"blur(0px)\" },\n  center: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: none },\n  exit: { opacity: 0, transition: none },\n};\n\nexport function PresenceSwap<K extends Key>({\n  value,\n  children,\n  variant = \"slide\",\n  order,\n  direction,\n  distance = 24,\n  animateHeight = true,\n  instant = false,\n  className,\n  ...rest\n}: PresenceSwapProps<K>) {\n  const reduce = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  // Remember the last value so the next swap knows which way it's going.\n  const [shown, setShown] = useState({ value, dir: 1 as Direction, swaps: 0 });\n  if (shown.value !== value) {\n    setShown({ value, dir: direction ?? directionOf(shown.value, value, order), swaps: shown.swaps + 1 });\n  }\n\n  // A view on its way out can't be clicked, tabbed into or read. If focus was\n  // inside it, hand focus to the view coming in, so keyboard users never land on\n  // <body>: its [data-autofocus] element if it has one, else the view itself.\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root || shown.swaps === 0) return;\n    const panes = Array.from(root.querySelectorAll<HTMLElement>(\":scope > div > [data-swap-pane]\"));\n    const leaving = panes.filter((p) => p.dataset.swapPane === \"leaving\");\n    const entering = panes.find((p) => p.dataset.swapPane === \"shown\");\n    const hadFocus = leaving.some((p) => p.contains(document.activeElement));\n    for (const p of leaving) p.inert = true;\n    if (hadFocus && entering) {\n      const target = entering.querySelector<HTMLElement>(\"[data-autofocus], [autofocus]\") ?? entering;\n      target.focus({ preventScroll: true });\n    }\n  }, [shown.swaps]);\n\n  const custom: Custom = { dir: shown.dir, distance };\n\n  return (\n    <AnimateHeight\n      ref={rootRef}\n      instant={instant || !animateHeight}\n      data-variant={variant}\n      data-direction={shown.dir === 1 ? \"forward\" : \"back\"}\n      className={cn(\"min-w-0\", className)}\n      // popLayout lifts the leaving view out of the flow, positioned against this box.\n      contentClassName=\"relative\"\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={custom}>\n        <Pane key={value} custom={custom} variants={instant ? immediate : reduce ? reduced : variants[variant]}>\n          {children}\n        </Pane>\n      </AnimatePresence>\n    </AnimateHeight>\n  );\n}\n\nfunction Pane({\n  custom,\n  variants,\n  children,\n  ref,\n}: {\n  custom: Custom;\n  variants: Variants;\n  children: React.ReactNode;\n  ref?: React.Ref<HTMLDivElement>;\n}) {\n  const present = useIsPresent();\n  return (\n    <motion.div\n      ref={ref}\n      data-swap-pane={present ? \"shown\" : \"leaving\"}\n      aria-hidden={present ? undefined : true}\n      tabIndex={-1}\n      custom={custom}\n      variants={variants}\n      initial=\"enter\"\n      animate=\"center\"\n      exit=\"exit\"\n      className={cn(\"outline-none\", !present && \"pointer-events-none\")}\n    >\n      {children}\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/presence-swap"
}
