{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-text-swap",
  "title": "Text Swap primitive",
  "description": "The text-change engine: outgoing text lifts 3px and blurs out, incoming rises 4px and sharpens, and the box springs to the new width. Shared by Button, Field messages and toasts.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/text-swap/text-swap.tsx",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useLayoutEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from \"react\";\nimport { exitFast, reducedFade, smooth, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../../lib/motion\";\n\nexport type TextSwapProps = Omit<ComponentPropsWithoutRef<\"span\">, \"children\"> & {\n  /** The text (or icon + text) to show. A new value swaps out the old one. */\n  children: ReactNode;\n  /**\n   * What counts as \"new content\". Defaults to `children` when it is a string or number, so\n   * `<TextSwap>{saved ? \"Saved\" : \"Save\"}</TextSwap>` just works. Pass a key when the\n   * children are elements, e.g. `swapKey={status}`.\n   */\n  swapKey?: string | number;\n  /** 1 (default): the new text rises from below, the old leaves upward. -1 reverses it, e.g. for a count going down. */\n  direction?: 1 | -1;\n  /** Spring the box's width to fit the new text, so neighbours glide instead of jumping. Default true. */\n  animateWidth?: boolean;\n  /** Animate the first render too. Default false: content that is already there does not perform. */\n  initial?: boolean;\n};\n\n/**\n * The Eased text-change primitive (rule 82): the outgoing text lifts 3px and blurs out, the\n * incoming text rises 4px from below and sharpens, and the box springs to the new width.\n * Position rides the snappy spring; opacity and blur are tweened on their own clock so blur\n * can never overshoot below zero. Under reduced motion it is a 100ms cross-fade and the\n * width changes at once. The outgoing copy is hidden from assistive tech while it leaves.\n *\n * Text never wraps inside a swap: it is for labels, statuses and short values.\n */\nexport function TextSwap({\n  children,\n  swapKey,\n  direction = 1,\n  animateWidth = true,\n  initial = false,\n  className = \"\",\n  style,\n  ...props\n}: TextSwapProps) {\n  const reduced = useSafeReducedMotion();\n  const key = swapKey ?? (typeof children === \"string\" || typeof children === \"number\" ? children : \"static\");\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const current = useRef<HTMLSpanElement | null>(null);\n  const settings = useRef({ reduced });\n  useLayoutEffect(() => {\n    settings.current = { reduced };\n  });\n\n  // Follow the entering copy's intrinsic width (it is nowrap, so the box never squeezes it).\n  // ResizeObserver reports after layout and before paint, so the first frame is already right.\n  useLayoutEffect(() => {\n    const el = current.current;\n    if (!el || !animateWidth) {\n      width.jump(\"auto\");\n      return;\n    }\n    let spring: AnimationPlaybackControls | undefined;\n    let target: number | undefined;\n    const ro = new ResizeObserver(([entry]) => {\n      const node = entry.target as HTMLElement;\n      const to = node.offsetWidth;\n      // A copy removed by an interrupted swap reads 0: fall back to auto rather than\n      // springing the box shut. An empty label is a real 0 and still springs.\n      if (!node.isConnected) {\n        target = undefined;\n        width.jump(\"auto\");\n        return;\n      }\n      if (to === target) return;\n      target = to;\n      if (width.get() === \"auto\" || settings.current.reduced) width.jump(to);\n      else spring = animate(width, to, smooth);\n    });\n    ro.observe(el);\n    // A copy that re-enters mid-exit is back in flow a frame later: measure it once it is.\n    const frame = requestAnimationFrame(() => {\n      ro.disconnect();\n      ro.observe(current.current ?? el);\n    });\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      spring?.stop();\n    };\n  }, [key, animateWidth, width]);\n\n  return (\n    // A spring on `width` is a deliberate exception to rule 75: one inline box, a handful of\n    // frames, and it is what lets the siblings glide rather than snap.\n    <motion.span\n      {...(props as ComponentPropsWithoutRef<typeof motion.span>)}\n      style={{ ...style, width }}\n      className={`relative inline-flex max-w-full items-center gap-[inherit] align-baseline ${className}`}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={initial}>\n        <SwapItem key={key} itemRef={current} direction={direction} reduced={reduced}>\n          {children}\n        </SwapItem>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\n// `ref` is forwarded: AnimatePresence's popLayout needs it to lift the outgoing copy out of flow.\nfunction SwapItem({\n  children,\n  itemRef,\n  direction,\n  reduced,\n  ref,\n}: {\n  children: ReactNode;\n  itemRef: React.RefObject<HTMLSpanElement | null>;\n  direction: 1 | -1;\n  reduced: boolean;\n  ref?: React.Ref<HTMLSpanElement>;\n}) {\n  const present = useIsPresent();\n  const node = useRef<HTMLSpanElement | null>(null);\n  // Claim the measured slot from an effect, not the ref callback: a copy that re-enters\n  // mid-exit keeps its node, so its ref callback can run before the leaving copy's.\n  useLayoutEffect(() => {\n    if (present) itemRef.current = node.current;\n  }, [present, itemRef]);\n  return (\n    <motion.span\n      ref={(el) => {\n        node.current = el;\n        if (typeof ref === \"function\") ref(el);\n        else if (ref) ref.current = el;\n      }}\n      aria-hidden={present ? undefined : true}\n      initial={reduced ? { opacity: 0 } : { ...swapIn, y: swapIn.y * direction }}\n      animate={reduced ? { opacity: 1 } : swapShown}\n      exit={\n        reduced\n          ? { opacity: 0, transition: reducedFade }\n          : { ...swapOut, y: swapOut.y * direction, transition: { ...swap, opacity: exitFast, filter: exitFast } }\n      }\n      transition={reduced ? reducedFade : swap}\n      className=\"inline-flex shrink-0 items-center gap-[inherit] whitespace-nowrap\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/text-swap/text-swap.tsx"
    }
  ],
  "type": "registry:ui"
}