{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:ui",
  "title": "Toast",
  "description": "Toasts arrive from just below the corner and leave sideways. Older ones stack behind the newest and open into a list on hover or focus.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/toast.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/toast 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Toast: custom, no Radix. The other half of the expressive moment.\n *\n * - <Toaster> is a provider plus a portal. Mount it once around the part of the\n *   app that fires toasts; children call useToast(). A bare toast() also works\n *   and routes to the Toaster mounted last.\n * - Flat: an ink pill with canvas words and buttons; an error keeps canvas too, since red\n *   would not read on ink.\n * - Look: litt-glass (float shadow), rounded-litt-surface, 360px, at least 72px tall and\n *   taller when its words wrap (WCAG 1.4.10 and 1.4.12): the title takes up to two\n *   lines and the description up to four, so a narrow screen or larger text never cuts\n *   what to do. No status dot: an error's title is --litt-danger, the rest are --litt-fg, and the\n *   words say the rest. A small ghost action and a round close button.\n * - Stack: the newest is in front. Older toasts sit behind, 8px and 16px up,\n *   scaled 0.96 and 0.92, fading, cut to the front toast's height so only their top\n *   edges show. Beyond three they wait out of sight and move up as space frees. Hover\n *   or keyboard focus expands the stack into a list, each toast at its own measured\n *   height. Only transform and opacity move; a height snaps, never animates.\n * - Motion: entering is expressive (from below by useTravel(), scale 0.98 to 1).\n *   Restacking is responsive. Exiting is useExit(\"expressive\"): x by travel\n *   toward the right edge, fading, never bouncing.\n * - Behavior: auto-dismiss after 5s (the spec); errors and toasts with an action stay. Paused while hovered,\n *   focused, held by a finger or when the tab is hidden. Escape dismisses the\n *   focused toast. F8 moves focus to the newest toast. The Toaster keeps two empty,\n *   hidden live regions, polite and (for errors) assertive, and writes a toast's\n *   title and description into one after it appears, which screen readers hear\n *   more reliably than a region that arrives with its words already in it.\n * - Touch: there is no hover, so tapping the stack pins it open; tap outside to\n *   close it again.\n *\n * On phones and touch (foundations.md section 6; both signals come from useDevice,\n * so <DeviceProvider mode=\"phone\"> previews them on a desktop):\n * - compact (narrower than 640): the stack spans the page gutters and sits\n *   --litt-space-4 above the bottom edge or the safe-area inset. A toast that leaves\n *   by itself (timeout, close, Esc) drops by --litt-travel instead of going sideways.\n * - compact, while a bottom sheet is open (useSheetOpen): the stack docks at the top\n *   edge instead, --litt-space-2 below the safe-area inset where the command menu sits,\n *   so it never covers the sheet's buttons. It arrives from above, older toasts peek\n *   below the newest, and a timeout lifts it by --litt-travel. The stack picks its edge\n *   when its first toast arrives and keeps it until it empties, so a toast never\n *   jumps between edges when the sheet closes.\n * - coarse (a finger): the front toast follows a sideways drag one to one, through\n *   useDismissDrag. Past --litt-dismiss-distance of its width, or faster than\n *   --litt-dismiss-velocity, it carries on the way it was going on the exit token;\n *   otherwise it springs back. The dismiss button stays and is 44px, so a swipe is\n *   never the only way. Fine pointers get no drag.\n *\n * Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): a mouse can throw the front toast away too, and\n * a thrown toast leaves with the speed it was let go at, on fun mode's throw spring, as it fades. One let go short\n * springs back with a little life. The close button is still there. Reduced motion turns it off.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useDevice, useFun, useSheetOpen, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useDismissDrag, type DismissInfo } from \"../../lib/litt/motion/drag\";\nimport { Button } from \"./button\";\nimport { XIcon } from \"./icons\";\n\nexport type ToastTone = \"neutral\" | \"success\" | \"error\";\n\nexport type ToastOptions = {\n  /** The message. Keep it to a few words: it wraps to two lines at most. */\n  title: ReactNode;\n  /** More detail in a quieter color, such as what to do next. It wraps to four lines at most, and the toast grows to fit. */\n  description?: ReactNode;\n  /** \"neutral\" (the default), \"success\" or \"error\". An error's title is the danger color; errors are announced at once and stay until dismissed. */\n  tone?: ToastTone;\n  /** A small button on the right, such as Undo or View. Pressing it runs onClick and then dismisses the toast. A toast with an action stays until dismissed, so there is time to reach it. */\n  action?: { label: string; onClick: () => void };\n  /** Milliseconds before it dismisses itself. The default is 5000; errors and toasts with an action stay unless you set it. 0 or Infinity stays until dismissed. */\n  duration?: number;\n};\n\ntype ToastItem = ToastOptions & { id: string };\n\ntype ToastApi = {\n  toast: (options: ToastOptions) => string;\n  dismiss: (id: string) => void;\n  dismissAll: () => void;\n};\n\n/* Stack geometry. A toast is at least MIN_HEIGHT tall, and is taken as that until it has been measured. */\n/** Flat: an ink pill, solid, no hairline; its buttons read canvas on it. */\nconst FLAT_PILL =\n  \"in-[[data-material=flat]]:rounded-litt-control in-[[data-material=flat]]:[--litt-glass-bg:var(--litt-fg)] in-[[data-material=flat]]:[--litt-glass-border:transparent] \" +\n  \"in-[[data-material=flat]]:[--litt-highlight:0_0_0_0_transparent] in-[[data-material=flat]]:[--litt-glass-blur:0px] \";\nconst FLAT_BUTTON =\n  \"in-[[data-material=flat]]:text-litt-canvas in-[[data-material=flat]]:hover:bg-[color-mix(in_oklab,var(--litt-canvas)_16%,transparent)] \" +\n  \"in-[[data-material=flat]]:focus-visible:outline-litt-canvas\";\n\nconst MAX_VISIBLE = 3;\nconst MIN_HEIGHT = 72;\nconst GAP = 8;\nconst PEEK = 8;\nconst SCALES = [1, 0.96, 0.92];\nconst FADES = [1, 0.8, 0.55];\nconst AUTO_DISMISS_MS = 5000;\n\ntype Slot = { y: number; scale: number; opacity: number };\n\n/* Where a toast sits in the stack. Docked at the bottom, older toasts go up; docked at the top, they go down.\n   `before` is how far the expanded list puts it from the edge: the newer visible toasts' heights and gaps. */\nfunction slotFor(depth: number, expanded: boolean, top: boolean, before: number): Slot {\n  const d = Math.min(depth, MAX_VISIBLE - 1);\n  const away = depth >= MAX_VISIBLE;\n  const way = top ? 1 : -1;\n  return expanded\n    ? { y: way * before, scale: 1, opacity: away ? 0 : 1 }\n    : { y: way * d * PEEK, scale: SCALES[d], opacity: away ? 0 : FADES[d] };\n}\n\nconst ToastContext = createContext<ToastApi | null>(null);\n\n/** Toasters that are mounted, newest last. toast() talks to the last one. */\nconst sinks: Array<(options: ToastOptions) => string> = [];\n\n/**\n * Fire a toast from anywhere, without a hook. It goes to the Toaster mounted last, and does nothing\n * (it returns an empty string) when no Toaster is mounted.\n *\n * @param options What to show. Only the title is required.\n * @returns An id you can pass to dismiss().\n */\nexport function toast(options: ToastOptions): string {\n  return sinks[sinks.length - 1]?.(options) ?? \"\";\n}\n\n/**\n * The Toaster's controls: toast(options) shows a toast and returns its id, dismiss(id) removes one, and\n * dismissAll() clears the stack. Use it inside a Toaster, or it throws.\n */\nexport function useToast(): ToastApi {\n  const api = useContext(ToastContext);\n  if (!api) throw new Error(\"useToast must be used inside <Toaster>\");\n  return api;\n}\n\n/* False while a server renders and while the client hydrates, true after, so the portal never causes a hydration mismatch. */\nconst subscribeNever = () => () => {};\n\n/**\n * Holds the toasts and draws them in a corner of the screen, above everything else. Mount it once, around\n * the part of the app that fires toasts. A Toaster inside another one adds nothing: its toasts go to the outer one,\n * so a page that nests them (a section that brings its own) still has one stack and one Notifications region.\n */\nexport function Toaster({\n  children,\n}: {\n  /** The part of the app that fires toasts. It renders as it is; the toasts themselves are drawn in a portal on the page body. */\n  children?: ReactNode;\n}) {\n  const outer = useContext(ToastContext);\n  return outer ? <>{children}</> : <ToasterRoot>{children}</ToasterRoot>;\n}\n\nfunction ToasterRoot({ children }: { children?: ReactNode }) {\n  const mounted = useSyncExternalStore(subscribeNever, () => true, () => false);\n  const [items, setItems] = useState<ToastItem[]>([]);\n  const counter = useRef(0);\n\n  const push = useCallback((options: ToastOptions) => {\n    const id = `toast-${++counter.current}`;\n    setItems((list) => [...list, { ...options, id }]);\n    return id;\n  }, []);\n  const dismiss = useCallback((id: string) => setItems((list) => list.filter((t) => t.id !== id)), []);\n  const dismissAll = useCallback(() => setItems([]), []);\n\n  useEffect(() => {\n    sinks.push(push);\n    return () => {\n      const at = sinks.indexOf(push);\n      if (at >= 0) sinks.splice(at, 1);\n    };\n  }, [push]);\n\n  const api = useMemo<ToastApi>(() => ({ toast: push, dismiss, dismissAll }), [push, dismiss, dismissAll]);\n\n  return (\n    <ToastContext value={api}>\n      {children}\n      {mounted && createPortal(<ToastRegion items={items} dismiss={dismiss} />, document.body)}\n    </ToastContext>\n  );\n}\n\ntype Announce = (id: string, text: string, urgent: boolean) => void;\n\n/* The words go in a frame after the region is cleared, so the same words twice are heard twice. Toasts that arrive in\n   one frame are read together. Keyed by toast, so a strict-mode double mount reads it once. */\nfunction useAnnouncer() {\n  const status = useRef<HTMLDivElement>(null);\n  const alert = useRef<HTMLDivElement>(null);\n  const queued = useRef({ status: new Map<string, string>(), alert: new Map<string, string>() });\n  const frame = useRef(0);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n  const announce = useCallback<Announce>((id, text, urgent) => {\n    const region = (urgent ? alert : status).current;\n    queued.current[urgent ? \"alert\" : \"status\"].set(id, text);\n    if (region) region.textContent = \"\";\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(() => {\n      for (const [q, el] of [[queued.current.status, status.current], [queued.current.alert, alert.current]] as const) {\n        if (q.size && el) el.textContent = [...q.values()].join(\" \");\n        q.clear();\n      }\n    });\n  }, []);\n  return { status, alert, announce };\n}\n\n/* The title and the description, each read as a sentence. */\nfunction spoken(words: HTMLElement): string {\n  return [...words.children]\n    .map((el) => el.textContent?.trim() ?? \"\")\n    .filter(Boolean)\n    .map((line) => (/[.!?…]$/.test(line) ? line : line + \".\"))\n    .join(\" \");\n}\n\nfunction ToastRegion({ items, dismiss }: { items: ToastItem[]; dismiss: (id: string) => void }) {\n  const { compact, coarse } = useDevice();\n  const live = useAnnouncer();\n  /* The edge is chosen while the stack is empty and kept until it empties again, so a toast never jumps. */\n  const sheetOpen = useSheetOpen();\n  const overSheet = compact && sheetOpen;\n  const [docked, setDocked] = useState(overSheet);\n  if (items.length === 0 && docked !== overSheet) setDocked(overSheet);\n  const top = compact && docked;\n  const regionRef = useRef<HTMLElement>(null);\n  const returnFocus = useRef<HTMLElement | null>(null);\n  const [hover, setHover] = useState(false);\n  const [focusIn, setFocusIn] = useState(false);\n  const [kbd, setKbd] = useState(false);\n  const [pinned, setPinned] = useState(false);\n  const [tabHidden, setTabHidden] = useState(() => document.visibilityState === \"hidden\");\n\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const measured = useCallback((id: string, h: number | null) => {\n    setHeights((all) => {\n      if (all[id] === h) return all;\n      const next = { ...all };\n      if (h === null) delete next[id];\n      else next[id] = h;\n      return next;\n    });\n  }, []);\n\n  const expanded = hover || kbd || pinned;\n  const paused = hover || focusIn || pinned || tabHidden;\n  const count = Math.min(items.length, MAX_VISIBLE);\n  /* Newest first: before[d] is the room the newer visible toasts take in the expanded list. */\n  const sizes = items.map((t) => heights[t.id] ?? MIN_HEIGHT).reverse();\n  const before = [0];\n  for (let d = 1; d < count; d++) before.push(before[d - 1] + sizes[d - 1] + GAP);\n  const front = sizes[0] ?? MIN_HEIGHT;\n  const height = count === 0 ? 0 : expanded ? before[count - 1] + sizes[count - 1] : front + (count - 1) * PEEK;\n\n  useEffect(() => {\n    const onVisibility = () => setTabHidden(document.visibilityState === \"hidden\");\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n  }, []);\n\n  /* A removed element fires no blur and no pointerleave, so re-read the truth. */\n  const syncFocus = useCallback(() => {\n    const region = regionRef.current;\n    const active = document.activeElement;\n    const inside = !!region && !!active && region.contains(active);\n    setFocusIn(inside);\n    setKbd(inside && !!active && active.matches(\":focus-visible\"));\n  }, []);\n\n  useEffect(() => {\n    syncFocus();\n    if (items.length === 0) {\n      setHover(false);\n      setPinned(false);\n    }\n  }, [items.length, syncFocus]);\n\n  /* F8 jumps to the newest toast, the way Radix does. */\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"F8\" || items.length === 0) return;\n      e.preventDefault();\n      const newest = items[items.length - 1];\n      regionRef.current?.querySelector<HTMLElement>(`[data-toast-id=\"${newest.id}\"] button`)?.focus();\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [items]);\n\n  /* On touch the stack opens by tap and closes by tapping elsewhere. */\n  useEffect(() => {\n    if (!pinned) return;\n    const onDown = (e: PointerEvent) => {\n      if (!regionRef.current?.contains(e.target as Node)) setPinned(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [pinned]);\n\n  return (\n    <section\n      ref={regionRef}\n      aria-label=\"Notifications (F8)\"\n      className={\n        \"fixed z-60 \" +\n        (top\n          ? \"inset-x-litt-gutter top-[calc(var(--litt-space-2)+env(safe-area-inset-top,0px))] mx-auto max-w-[40rem] \"\n          : compact\n            ? \"inset-x-litt-gutter bottom-[calc(var(--litt-space-4)+env(safe-area-inset-bottom,0px))] mx-auto max-w-[40rem] \"\n            : \"right-6 bottom-[max(1.5rem,env(safe-area-inset-bottom,0px))] w-90 \") +\n        (count === 0 ? \"pointer-events-none\" : \"\")\n      }\n      style={{ height }}\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") setHover(true);\n      }}\n      onPointerLeave={() => setHover(false)}\n      onFocus={(e) => {\n        const from = e.relatedTarget as HTMLElement | null;\n        if (from && !e.currentTarget.contains(from)) returnFocus.current = from;\n        setFocusIn(true);\n        setKbd(e.target.matches(\":focus-visible\"));\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {\n          setFocusIn(false);\n          setKbd(false);\n        }\n      }}\n      onClick={(e) => {\n        if (coarse && items.length > 1 && !(e.target as HTMLElement).closest(\"button\")) setPinned((p) => !p);\n      }}\n      onKeyDown={(e) => {\n        if (e.key !== \"Escape\") return;\n        const id = (e.target as HTMLElement).closest<HTMLElement>(\"[data-toast-id]\")?.dataset.toastId;\n        if (!id) return;\n        const rest = items.filter((t) => t.id !== id);\n        dismiss(id);\n        const next = rest[rest.length - 1];\n        requestAnimationFrame(() => {\n          const target = next && regionRef.current?.querySelector<HTMLElement>(`[data-toast-id=\"${next.id}\"] button`);\n          (target || returnFocus.current)?.focus();\n        });\n      }}\n    >\n      <div ref={live.status} role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" />\n      <div ref={live.alert} role=\"alert\" aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\" />\n      <AnimatePresence onExitComplete={syncFocus}>\n        {items.map((item, i) => {\n          const depth = items.length - 1 - i;\n          return (\n            <ToastView\n              key={item.id}\n              item={item}\n              depth={depth}\n              slot={slotFor(depth, expanded, top, before[Math.min(depth, count - 1)])}\n              fit={!expanded && depth > 0 ? front : undefined}\n              expanded={expanded}\n              paused={paused}\n              top={top}\n              onDismiss={dismiss}\n              onMeasure={measured}\n              onAnnounce={live.announce}\n            />\n          );\n        })}\n      </AnimatePresence>\n    </section>\n  );\n}\n\nfunction ToastView({\n  item,\n  depth,\n  slot,\n  fit,\n  expanded,\n  paused,\n  top,\n  onDismiss,\n  onMeasure,\n  onAnnounce,\n}: {\n  item: ToastItem;\n  depth: number;\n  slot: Slot;\n  /** The front toast's height, while this one peeks from behind it in the folded stack. */\n  fit: number | undefined;\n  expanded: boolean;\n  paused: boolean;\n  top: boolean;\n  onDismiss: (id: string) => void;\n  onMeasure: (id: string, height: number | null) => void;\n  onAnnounce: Announce;\n}) {\n  const { compact, coarse } = useDevice();\n  const enter = useTransition(\"expressive\");\n  const move = useTransition(\"responsive\");\n  const travel = useTravel();\n  const settled = useRef(false);\n  const node = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const words = useRef<HTMLDivElement>(null);\n  /* The way a swipe sent this toast. A timeout, the close button and Esc leave it null: they drop on compact\n     screens and go right elsewhere. */\n  const swiped = useRef<\"left\" | \"right\" | null>(null);\n  /* Fun mode: how fast the swipe was going when it let go, in px/ms, so the toast leaves at that speed. */\n  const thrown = useRef(0);\n  const fun = useFun();\n  const [held, setHeld] = useState(false);\n\n  const tone = item.tone ?? \"neutral\";\n  const away = depth >= MAX_VISIBLE;\n  /* Arrivals and timeouts come from, and go back to, the edge the stack is docked at. */\n  const edge = top ? -travel : travel;\n  const showContent = depth === 0 || expanded;\n\n  /* A finger swipes a toast sideways, one to one. It is off for a mouse, which has the close button, except in fun\n     mode, where a mouse can throw it too. */\n  const drag = useDismissDrag({\n    direction: \"sideways\",\n    tier: \"expressive\",\n    enabled: coarse || !!fun,\n    onDismiss: (info: DismissInfo) => {\n      swiped.current = info.direction === \"down\" ? null : info.direction;\n      thrown.current = info.velocity;\n      onDismiss(item.id);\n    },\n  });\n\n  /* The content's own height, which a folded toast behind the front keeps even while its box is cut to the front's. */\n  useLayoutEffect(() => {\n    const el = content.current;\n    if (!el) return;\n    const read = () => onMeasure(item.id, el.offsetHeight);\n    read();\n    if (typeof ResizeObserver === \"undefined\") return () => onMeasure(item.id, null);\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      onMeasure(item.id, null);\n    };\n  }, [item.id, onMeasure]);\n\n  useEffect(() => {\n    if (words.current) onAnnounce(item.id, spoken(words.current), tone === \"error\");\n  }, [item.id, tone, onAnnounce]);\n\n  /* A finger resting on a toast holds its clock, so it never leaves under the thumb. */\n  useEffect(() => {\n    if (!held) return;\n    const release = () => setHeld(false);\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    return () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n    };\n  }, [held]);\n\n  const wanted = item.duration ?? (tone === \"error\" || item.action ? Infinity : AUTO_DISMISS_MS);\n  const persistent = !Number.isFinite(wanted) || wanted <= 0;\n  const remaining = useRef(persistent ? 0 : wanted);\n  const idle = persistent || paused || away || held;\n\n  /* The clock only runs while the toast is visible and nobody is reading it. */\n  useEffect(() => {\n    if (idle) return;\n    const started = performance.now();\n    const timer = window.setTimeout(() => onDismiss(item.id), Math.max(remaining.current, 0));\n    return () => {\n      window.clearTimeout(timer);\n      remaining.current -= performance.now() - started;\n    };\n  }, [idle, item.id, onDismiss]);\n\n  return (\n    <motion.div\n      ref={node}\n      {...drag.panel}\n      data-toast-id={item.id}\n      data-tone={tone}\n      inert={away}\n      className={\"litt-glass absolute inset-x-0 rounded-litt-surface \" + FLAT_PILL + (top ? \"top-0 origin-bottom\" : \"bottom-0 origin-top\")}\n      style={fit ? { ...drag.panel.style, height: fit } : drag.panel.style}\n      onPointerDown={(e) => {\n        /* Only a toast that shows its content can be swiped; the ones peeking from behind wait their turn. */\n        if (!showContent) return;\n        drag.onPointerDown(e);\n        if (coarse) setHeld(true);\n      }}\n      initial={{ opacity: 0, y: slot.y + edge, scale: 0.98 }}\n      animate={{ opacity: slot.opacity, y: slot.y, scale: slot.scale, transition: settled.current ? move : enter }}\n      variants={{\n        /* Read when the exit starts: a swipe carries on one more toast width from where the finger let go, a\n           timeout drops (or, docked at the top, lifts) by the travel token on compact screens, and everywhere else\n           the toast goes right. */\n        leave: () => {\n          if (swiped.current) {\n            const now = drag.offset.get() as unknown;\n            const from = typeof now === \"number\" ? now : 0;\n            const by = node.current?.offsetWidth ?? 0;\n            const to = from + (swiped.current === \"left\" ? -by : by);\n            if (fun) {\n              /* Fun mode: it carries the speed it was thrown at, the way it was going, and fades on the exit token. */\n              const velocity = (swiped.current === \"left\" ? -1 : 1) * thrown.current * 1000;\n              return { opacity: 0, x: to, transition: { x: { ...fun.throw, velocity }, opacity: drag.exit } };\n            }\n            return { opacity: 0, x: to, transition: drag.exit };\n          }\n          return compact\n            ? { opacity: 0, y: slot.y + edge, transition: drag.exit }\n            : { opacity: 0, x: travel, transition: drag.exit };\n        },\n      }}\n      exit=\"leave\"\n      onAnimationComplete={() => {\n        settled.current = true;\n      }}\n    >\n      {/* A folded toast cut to the front's height hides the rest here: clipping the glass itself would cut its shadow. */}\n      <div className={\"h-full \" + (fit ? \"overflow-hidden\" : \"\")}>\n        <motion.div\n          ref={content}\n          className=\"flex min-h-18 items-center gap-3 px-4 py-3 in-[[data-material=flat]]:px-6\"\n          initial={false}\n          animate={{ opacity: showContent ? 1 : 0, transition: move }}\n        >\n          <div ref={words} className=\"min-w-0 flex-1\">\n            <p className={\"line-clamp-2 text-litt-small font-litt-title in-[[data-material=flat]]:text-litt-canvas \" + (tone === \"error\" ? \"text-litt-danger\" : \"text-litt-fg\")}>{item.title}</p>\n            {item.description && <p className=\"line-clamp-4 text-litt-caption font-normal text-litt-fg-subtle in-[[data-material=flat]]:text-litt-canvas in-[[data-material=flat]]:opacity-80\">{item.description}</p>}\n          </div>\n          {item.action && (\n            <Button\n              size=\"sm\"\n              variant=\"ghost\"\n              className={\"pointer-coarse:h-litt-control-md \" + FLAT_BUTTON}\n              onClick={() => {\n                item.action?.onClick();\n                onDismiss(item.id);\n              }}\n            >\n              {item.action.label}\n            </Button>\n          )}\n          <Button\n            size=\"sm\"\n            variant=\"ghost\"\n            iconOnly\n            aria-label=\"Dismiss notification\"\n            className={\"pointer-coarse:-mr-1.5 pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md \" + FLAT_BUTTON}\n            onClick={() => onDismiss(item.id)}\n          >\n            <XIcon />\n          </Button>\n        </motion.div>\n      </div>\n    </motion.div>\n  );\n}\n",
      "target": "components/litt/toast.tsx"
    }
  ],
  "docs": "Import Toaster, toast and useToast from components/litt/toast. Mount Toaster once, then call toast() anywhere.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
