{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/toast/toast.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n  type HTMLMotionProps,\n  type MotionProps,\n  type PanInfo,\n  type TargetAndTransition,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nexport interface ToastProps {\n  title: string;\n  description?: string;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  duration?: number;\n}\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 = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\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({\n  reduce,\n  morphKey,\n  children,\n}: {\n  reduce: boolean | null;\n  morphKey: string;\n  children: ReactNode;\n}) {\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(() => {\n    changedAt.current = performance.now();\n  }, [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 = () => {\n      height.jump(\"auto\");\n      if (frame.current) Object.assign(frame.current.style, { overflow: \"\", height: \"auto\" });\n    };\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 () => {\n      observer.disconnect();\n      controls?.stop();\n    };\n  }, [height, reduce]);\n  return (\n    <motion.div ref={frame} className=\"block min-w-0 flex-1\" style={{ height }}>\n      {/* The top padding centers the title's first line on the 30px status icon. */}\n      <div ref={content} className=\"relative grid min-w-0 gap-0.5 pt-[0.3125rem]\">\n        {children}\n      </div>\n    </motion.div>\n  );\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(\n    subscribeHydration,\n    () => true,\n    () => false,\n  );\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) {\n    setLastOpen(open);\n    if (open) {\n      setDismissed(false);\n      setThrowX(0);\n    }\n  }\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(() => {\n      setThrowX(0);\n      setDismissed(true);\n      onOpenChange(false);\n    }, 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    ? {\n        hidden: { opacity: 0 },\n        shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } },\n        exit: fadeOut,\n      }\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: 0.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) =>\n          to\n            ? {\n                x: to,\n                opacity: 0,\n                transition: {\n                  x: {\n                    type: \"spring\",\n                    visualDuration: 0.3,\n                    bounce: 0,\n                    velocity: releaseVelocity.current,\n                    restDelta: 2,\n                    restSpeed: 40,\n                  },\n                  opacity: { duration: motionTokens.duration.exit },\n                },\n              }\n            : {\n                opacity: 0,\n                y: 8,\n                scale: 0.97,\n                transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] },\n              },\n      };\n  const swap: MotionProps = {\n    initial: reduce ? { opacity: 0 } : textIn,\n    animate: textShown,\n    exit: reduce ? fadeOut : textOut,\n    transition: reduce ? { duration: motionTokens.duration.instant } : enter,\n  };\n\n  return (\n    <AnimatePresence initial={enterOnMount} custom={throwX}>\n      {visible && (\n        <motion.div\n          key=\"toast\"\n          ref={surface}\n          className=\"flex w-[min(100%,26rem)] min-w-0 items-start gap-3 rounded-panel border border-line bg-[color-mix(in_oklab,var(--surface)_94%,var(--page))] py-3.5 pr-3.5 pl-4 tracking-body text-ink shadow-floating\"\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\n            className=\"grid size-[1.875rem] flex-[0_0_auto] place-items-center rounded-full border border-[color-mix(in_oklab,var(--green)_24%,var(--line))] bg-[color-mix(in_oklab,var(--green)_10%,var(--surface))] text-green\"\n            aria-hidden=\"true\"\n          >\n            <svg\n              width={16}\n              height={16}\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2.25}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <motion.path\n                d=\"M4 12.5l5 5L20 6.5\"\n                initial={reduce ? false : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{\n                  duration: motionTokens.duration.standard,\n                  ease: [...motionTokens.ease.enter],\n                  delay: 0.12,\n                }}\n              />\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=\"relative block min-w-0 text-(length:--text-sm) leading-body font-medium\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <Swap key={title} className=\"block overflow-hidden text-ellipsis whitespace-nowrap\" {...swap}>\n                  {title}\n                </Swap>\n              </AnimatePresence>\n            </strong>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {description ? (\n                <Swap key={description} className=\"block text-(length:--text-sm) leading-body text-ink-2\" {...swap}>\n                  {description}\n                </Swap>\n              ) : null}\n            </AnimatePresence>\n          </HeightFrame>\n          <button\n            className=\"-mt-px mr-[-0.125rem] mb-0 ml-0 grid size-8 flex-[0_0_auto] cursor-pointer place-items-center rounded-button border-0 bg-transparent text-ink-3 transition-[scale,background-color,color] duration-[var(--duration-spring),var(--duration-fast),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard),var(--ease-standard)] hover:bg-hover hover:text-ink active:scale-96 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-100\"\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      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/toast/toast.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/toast",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "Toast",
    "tags": [
      "status",
      "message"
    ],
    "docs": "https://irisui.dev/components/toast"
  }
}
