{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:ui",
  "title": "Toast",
  "description": "Brief confirmation for a completed background action.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/toast/toast.tsx",
      "type": "registry:component",
      "target": "@components/xzero/toast/toast.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, type AnimationPlaybackControls, type HTMLMotionProps, type MotionProps, type PanInfo, type TargetAndTransition, type Transition, type Variants } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./toast.module.css\";\nexport interface ToastProps { title: string; description?: string; open?: boolean; onOpenChange?: (open: boolean) => void; duration?: number; }\n\nconst subscribeHydration = () => () => {};\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\nconst textShown: TargetAndTransition = { opacity: 1, y: \"0em\", filter: \"blur(0px)\" };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** A swipe past this distance (px) or speed (px/s) dismisses the toast in the direction it was thrown. */\nconst swipe = { distance: 80, velocity: 480 };\n\n/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\n/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */\nfunction HeightFrame({ reduce, morphKey, children }: { reduce: boolean | null; morphKey: string; children: ReactNode }) {\n  const frame = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const changedAt = useRef(0);\n  useLayoutEffect(() => { changedAt.current = performance.now(); }, [morphKey]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: number | undefined;\n    let controls: AnimationPlaybackControls | undefined;\n    const settle = () => { height.jump(\"auto\"); if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\" }); };\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      const current = height.get();\n      const from = typeof current === \"number\" ? current : last;\n      last = next;\n      controls?.stop();\n      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();\n      // Pin the old height before this frame paints, then spring to the new one.\n      if (frame.current) Object.assign(frame.current.style, { overflow: \"hidden\", height: `${from}px` });\n      controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });\n    });\n    observer.observe(node);\n    return () => { observer.disconnect(); controls?.stop(); };\n  }, [height, reduce]);\n  return <motion.div ref={frame} className={styles.frame} style={{ height }}>\n    <div ref={content} className={styles.copy}>{children}</div>\n  </motion.div>;\n}\n\nexport default function Toast({ title, description, open = true, onOpenChange, duration = 4500 }: ToastProps) {\n  const reduce = useReducedMotion();\n  // Toasts that mount after hydration rise in from their edge; server-rendered ones start settled.\n  const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false);\n  const [enterOnMount] = useState(hydrated);\n  const [dismissed, setDismissed] = useState(false);\n  const [throwX, setThrowX] = useState(0);\n  const [lastOpen, setLastOpen] = useState(open);\n  if (open !== lastOpen) { setLastOpen(open); if (open) { setDismissed(false); setThrowX(0); } }\n  const visible = open && !dismissed;\n  const surface = useRef<HTMLDivElement>(null);\n  // The drag offset is owned here, so a release hands its velocity straight to the spring home or to the throw.\n  const x = useMotionValue(0);\n  const releaseVelocity = useRef(0);\n\n  /** Closing tells the parent at once, while the exit plays out here, so a new toast can be raised mid-exit and simply turns back. */\n  function dismiss(to: number) {\n    setThrowX(to);\n    setDismissed(true);\n    onOpenChange?.(false);\n  }\n\n  useEffect(() => {\n    if (!visible || !onOpenChange || duration <= 0) return;\n    const timer = window.setTimeout(() => { setThrowX(0); setDismissed(true); onOpenChange(false); }, duration);\n    return () => window.clearTimeout(timer);\n  }, [visible, onOpenChange, duration]);\n\n  function handleDragEnd(_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) {\n    const direction = Math.sign(info.offset.x || info.velocity.x);\n    // A short swipe springs back with its release velocity and a slight settle instead of drifting home.\n    if (Math.abs(info.offset.x) < swipe.distance && Math.abs(info.velocity.x) < swipe.velocity) {\n      animate(x, 0, { ...motionTokens.spring.snappy, velocity: info.velocity.x });\n      return;\n    }\n    releaseVelocity.current = info.velocity.x;\n    dismiss(direction * ((surface.current?.offsetWidth ?? 320) + 48));\n  }\n\n  const variants: Variants = reduce ? {\n    hidden: { opacity: 0 },\n    shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } },\n    exit: fadeOut,\n  } : {\n    // x resets here because the drag value outlives a thrown toast; the next one must rise from the center.\n    hidden: { opacity: 0, x: 0, y: 16, scale: .96 },\n    // x returns to center too, in case the toast is raised again while a throw is still leaving.\n    shown: { opacity: 1, x: 0, y: 0, scale: 1, transition: { ...motionTokens.spring.morph, opacity: enter } },\n    // A thrown toast keeps its release velocity; a closed one sinks back toward the edge it came from.\n    // The throw settles loosely: it is invisible once the fade ends, so no long unseen tail keeps it mounted.\n    exit: (to: number) => to\n      ? { x: to, opacity: 0, transition: { x: { type: \"spring\", visualDuration: .3, bounce: 0, velocity: releaseVelocity.current, restDelta: 2, restSpeed: 40 }, opacity: { duration: motionTokens.duration.exit } } }\n      : { opacity: 0, y: 8, scale: .97, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } },\n  };\n  const swap: MotionProps = { initial: reduce ? { opacity: 0 } : textIn, animate: textShown, exit: reduce ? fadeOut : textOut, transition: reduce ? { duration: motionTokens.duration.instant } : enter };\n\n  return (\n    <AnimatePresence initial={enterOnMount} custom={throwX}>\n      {visible && <motion.div\n        key=\"toast\"\n        ref={surface}\n        className={styles.toast}\n        role=\"status\"\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        custom={throwX}\n        variants={variants}\n        initial=\"hidden\"\n        animate=\"shown\"\n        exit=\"exit\"\n        style={{ x }}\n        // Drag writes touch-action into the markup, so the reduced motion switch waits for hydration to keep server and client in step.\n        drag={reduce && hydrated ? false : \"x\"}\n        dragMomentum={false}\n        onDragEnd={handleDragEnd}\n      >\n        <span className={styles.icon} aria-hidden=\"true\">\n          <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.25} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <motion.path d=\"M4 12.5l5 5L20 6.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: .12 }} />\n          </svg>\n        </span>\n        {/* The copy column follows its content height, so a longer message grows the toast instead of snapping it. */}\n        <HeightFrame reduce={reduce} morphKey={`${title}\\n${description ?? \"\"}`}>\n          <strong className={styles.title}><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={title} className={styles.line} {...swap}>{title}</Swap></AnimatePresence></strong>\n          <AnimatePresence mode=\"popLayout\" initial={false}>{description ? <Swap key={description} className={styles.description} {...swap}>{description}</Swap> : null}</AnimatePresence>\n        </HeightFrame>\n        <button\n          className={styles.close}\n          type=\"button\"\n          aria-label=\"Dismiss notification\"\n          onClick={() => dismiss(0)}\n        >\n          <X width={16} height={16} strokeWidth={2} aria-hidden=\"true\" />\n        </button>\n      </motion.div>}\n    </AnimatePresence>\n  );\n}\n"
    },
    {
      "path": "registry/components/toast/toast.module.css",
      "type": "registry:component",
      "target": "@components/xzero/toast/toast.module.css",
      "content": ".toast {\n  display: flex;\n  width: min(100%, 26rem);\n  min-width: 0;\n  align-items: flex-start;\n  gap: 0.75rem;\n  padding: 0.875rem 0.875rem 0.875rem 1rem;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-panel);\n  background: color-mix(in oklab, var(--surface) 94%, var(--background));\n  color: var(--foreground);\n  letter-spacing: var(--tracking-body);\n  box-shadow: var(--shadow-floating);\n}\n\n.icon {\n  display: grid;\n  width: 1.875rem;\n  height: 1.875rem;\n  flex: 0 0 auto;\n  place-items: center;\n  border: 1px solid color-mix(in oklab, var(--success) 24%, var(--border));\n  border-radius: var(--radius-pill);\n  background: color-mix(in oklab, var(--success) 10%, var(--surface));\n  color: var(--success);\n}\n\n.frame {\n  display: block;\n  min-width: 0;\n  flex: 1;\n}\n\n.copy {\n  position: relative;\n  display: grid;\n  min-width: 0;\n  gap: 0.125rem;\n  /* Centers the title's first line on the 30px status icon. */\n  padding-top: 0.3125rem;\n}\n\n.title {\n  position: relative;\n  display: block;\n  min-width: 0;\n  font-size: var(--text-sm);\n  font-weight: 500;\n  line-height: var(--leading-body);\n}\n\n.line {\n  display: block;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.description {\n  display: block;\n  color: var(--text-secondary);\n  font-size: var(--text-sm);\n  line-height: var(--leading-body);\n}\n\n.close {\n  display: grid;\n  width: 2rem;\n  height: 2rem;\n  flex: 0 0 auto;\n  place-items: center;\n  margin: -0.0625rem -0.125rem 0 0;\n  border: 0;\n  border-radius: var(--radius-pill);\n  background: transparent;\n  color: var(--text-muted);\n  cursor: pointer;\n  transition: transform var(--duration-spring) var(--ease-spring),\n    background-color var(--duration-fast) var(--ease-standard),\n    color var(--duration-fast) var(--ease-standard);\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .close:hover {\n    background: var(--surface-muted);\n    color: var(--foreground);\n  }\n}\n\n/* Quick press, spring release. */\n.close:active {\n  transform: scale(0.96);\n  transition-duration: var(--duration-instant);\n  transition-timing-function: var(--ease-standard);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .close { transition: none; }\n  .close:active { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/toast",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "status",
      "message"
    ],
    "docs": "https://ui.xzero.co.uk/components/toast",
    "markdown": "https://ui.xzero.co.uk/components/toast/markdown"
  }
}