FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Toast

Toast

pnpm dlx shadcn@latest add …/r/toast.json

An accessible React toast for Next.js: notifications that slide in, stack like a deck and swipe away, with a toast() function and Radix Toast parts.

import { Toaster, toast } from "@/components/ui/toast"

// Once, in your root layout
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <Toaster />
    </>
  )
}

// Anywhere in a client component
export function SaveButton() {
  return <button onClick={() => toast.success("Changes saved")}>Save</button>
}
types.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Toaster, type ToastPosition, toast } from "@/components/ui/toast";
import { useId, useState } from "react";

const positions: { value: ToastPosition; label: string }[] = [
  { value: "top-left", label: "Top left" },
  { value: "top-center", label: "Top centre" },
  { value: "top-right", label: "Top right" },
  { value: "bottom-left", label: "Bottom left" },
  { value: "bottom-center", label: "Bottom centre" },
  { value: "bottom-right", label: "Bottom right" },
];

/*
 * One button per kind of toast, with the corner they appear in at the top right. Show a few in a row to see
 * them stack, then hover them to fan them out.
 */
export default function ToastTypes() {
  const id = useId();
  const [position, setPosition] = useState<ToastPosition>("bottom-right");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-position`} className="text-sm text-muted-foreground">
          Position
        </label>
        <Select value={position} onValueChange={(value) => setPosition(value as ToastPosition)}>
          <SelectTrigger id={`${id}-position`} size="sm" className="w-36">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {positions.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-wrap items-center justify-center gap-3">
        <Button
          variant="outline"
          onClick={() => toast("Event created", { description: "Friday, 10:00 to 11:00" })}
        >
          Default
        </Button>
        <Button variant="outline" onClick={() => toast.success("Changes saved")}>
          Success
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast.error("Couldn't save changes", { description: "Check your connection and try again." })
          }
        >
          Error
        </Button>
        <Button
          variant="outline"
          onClick={() =>
            toast("Message archived", {
              action: { label: "Undo", onClick: () => toast("Message restored") },
            })
          }
        >
          With action
        </Button>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
      <Toaster position={position} />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority lucide-react motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Button, Spinner first.

  5. 5

    Copy the source into your project.

    components/ui/toast.tsx
    "use client";
    
    import { buttonVariants } from "@/components/ui/button";
    import { Spinner } from "@/components/ui/spinner";
    import { blur, duration, ease, spring, swap } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva } from "class-variance-authority";
    import { XIcon } from "lucide-react";
    import {
      AnimatePresence,
      type AnimationPlaybackControls,
      animate,
      motion,
      useMotionValue,
      useReducedMotion,
      type Variants,
    } from "motion/react";
    import { Portal, Toast as ToastPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      isValidElement,
      type MouseEvent,
      type ReactNode,
      type Ref,
      useCallback,
      useContext,
      useEffect,
      useId,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
      useSyncExternalStore,
    } from "react";
    
    /*
     * Toast: short notifications at a corner of the screen that stack and leave on their own (owner's roadmap,
     * 2026-10-16). Built on Radix Toast: each toast is announced to screen readers, F8 jumps to them, Escape
     * closes the newest, and they swipe away. The close timers are this file's own (see Toast): they wait while
     * the pointer or focus is on the toasts, or the window is in the background.
     * Two ways in:
     * - `<Toaster />` placed once, then `toast("Saved")` from anywhere (`toast.success`, `.error`, `.loading`,
     *   `.promise`, `.dismiss`), like sonner. The toasts live in a tiny store read with useSyncExternalStore.
     * - The parts (ToastProvider, Toast, ToastTitle, ToastDescription, ToastAction, ToastClose, ToastViewport),
     *   shaped like shadcn's, for toasts you render yourself with `open` state.
     * Motion (.agents/rules/motion.md):
     * - A new toast slides in from the screen edge through the blur; one that closes slides back out to it.
     * - Several stack like a deck: older toasts tuck behind the newest, each a little narrower, showing a 12px
     *   edge with their content hidden. Hovering, or keyboard focus, fans them out into a list. Every shift is on
     *   spring.snappy; a tucked toast takes the front one's height, and heights glide on an eased tween (the
     *   height pattern), never a spring.
     * - A swipe follows the finger. Let go past the threshold and it flies off; short of it, it springs back.
     * - A toast whose content changes (toast.promise settling) swaps it through the blur, like Loading Button.
     * - Reduced motion: opacity only, and the toasts sit in a plain list.
     * Error toasts keep the neutral surface: the title and the cross turn the destructive colour.
     */
    
    export type ToastPosition =
      | "top-left"
      | "top-center"
      | "top-right"
      | "bottom-left"
      | "bottom-center"
      | "bottom-right";
    
    /** The deck, in px: how much of each older toast shows behind the newest, and the gap once fanned out */
    const PEEK = 12;
    const GAP = 12;
    /** Each older toast in the deck is 5% narrower than the one in front of it */
    const SHRINK = 0.05;
    
    /* ------------------------------------------------------------------------------------------------------ */
    /* The stack: which toasts are open, how tall each is, and whether the deck is fanned out                  */
    
    type StackSettings = { position: ToastPosition; expand: boolean; visibleToasts: number };
    
    type Stack = StackSettings & {
      /** Open toasts, oldest first */
      ids: string[];
      /** Each open toast's full height, measured from its content */
      heights: Record<string, number>;
      /** Fanned out into a list: hovered, focused from the keyboard, `expand`, or reduced motion */
      expanded: boolean;
      /** The timers wait: the pointer is on the toasts, focus is in them, or the window is in the background */
      paused: boolean;
      /** How long a toast stays open, unless it sets its own duration */
      duration: number;
      reduce: boolean;
      add: (id: string) => void;
      remove: (id: string) => void;
      measure: (id: string, height: number) => void;
      configure: (settings: StackSettings) => void;
      setHovered: (hovered: boolean) => void;
      /** Where focus is: outside the toasts, in them from a click, or in them from the keyboard */
      setFocus: (focus: Focus) => void;
    };
    
    type Focus = "none" | "pointer" | "keyboard";
    
    const defaultSettings: StackSettings = { position: "bottom-right", expand: false, visibleToasts: 3 };
    const noop = () => {};
    // Outside a ToastProvider, a toast still shows; it just doesn't stack
    const looseStack: Stack = {
      ...defaultSettings,
      ids: [],
      heights: {},
      expanded: true,
      paused: false,
      duration: 5000,
      reduce: false,
      add: noop,
      remove: noop,
      measure: noop,
      configure: noop,
      setHovered: noop,
      setFocus: noop,
    };
    
    const StackContext = createContext<Stack>(looseStack);
    
    /** Hands a node to our ref (a state setter, so effects rerun when it mounts) and to the caller's */
    function useMergedRef<T>(own: (node: T | null) => void, theirs?: Ref<T>) {
      return useCallback(
        (node: T | null) => {
          own(node);
          if (typeof theirs === "function") theirs(node);
          else if (theirs) theirs.current = node;
        },
        [own, theirs],
      );
    }
    
    export type ToastProviderProps = Omit<ComponentProps<typeof ToastPrimitive.Provider>, "swipeDirection">;
    
    /**
     * Wraps the toasts and their viewport: Radix Toast's provider (the `label` screen readers say before each
     * toast, the swipe threshold), plus the stack the deck is drawn from and the default `duration`. Toasts
     * swipe towards the nearer side of the screen: left for left positions, right otherwise.
     */
    function ToastProvider({ children, duration = 5000, ...props }: ToastProviderProps) {
      const reduce = useReducedMotion() ?? false;
      const [settings, setSettings] = useState(defaultSettings);
      const [ids, setIds] = useState<string[]>([]);
      const [heights, setHeights] = useState<Record<string, number>>({});
      const [hovered, setHovered] = useState(false);
      const [focus, setFocus] = useState<Focus>("none");
      const [away, setAway] = useState(false);
      const empty = ids.length === 0;
    
      // With nothing left, the deck folds again and the timers are free: the pointer or focus may still be
      // where the last toast was
      useEffect(() => {
        if (!empty) return;
        setHovered(false);
        setFocus("none");
      }, [empty]);
    
      // The timers wait while the window is in the background, as Radix's do
      useEffect(() => {
        const leave = () => setAway(true);
        const back = () => setAway(false);
        window.addEventListener("blur", leave);
        window.addEventListener("focus", back);
        return () => {
          window.removeEventListener("blur", leave);
          window.removeEventListener("focus", back);
        };
      }, []);
    
      const add = useCallback(
        (id: string) => setIds((current) => (current.includes(id) ? current : [...current, id])),
        [],
      );
      const remove = useCallback((id: string) => {
        setIds((current) => current.filter((other) => other !== id));
        setHeights(({ [id]: _, ...rest }) => rest);
      }, []);
      const measure = useCallback(
        (id: string, height: number) =>
          setHeights((current) => (current[id] === height ? current : { ...current, [id]: height })),
        [],
      );
      const configure = useCallback(
        (next: StackSettings) =>
          setSettings((current) =>
            current.position === next.position &&
            current.expand === next.expand &&
            current.visibleToasts === next.visibleToasts
              ? current
              : next,
          ),
        [],
      );
    
      const expanded = settings.expand || reduce || hovered || focus === "keyboard";
      const paused = hovered || focus !== "none" || away;
      const stack = useMemo<Stack>(
        () => ({
          ...settings,
          ids,
          heights,
          expanded,
          paused,
          duration,
          reduce,
          add,
          remove,
          measure,
          configure,
          setHovered,
          setFocus,
        }),
        [settings, ids, heights, expanded, paused, duration, reduce, add, remove, measure, configure],
      );
    
      return (
        <ToastPrimitive.Provider
          swipeDirection={settings.position.endsWith("left") ? "left" : "right"}
          {...props}
        >
          <StackContext.Provider value={stack}>{children}</StackContext.Provider>
        </ToastPrimitive.Provider>
      );
    }
    
    /* ------------------------------------------------------------------------------------------------------ */
    /* Viewport                                                                                                */
    
    // 16px in from the screen's edges; full width on phones, 24rem from the sm breakpoint
    const toastViewportVariants = cva(
      [
        "fixed inset-x-4 z-(--z-toast) sm:w-sm",
        "rounded-lg outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
      ],
      {
        variants: {
          position: {
            "top-left": "top-4 sm:right-auto",
            "top-center": "top-4 sm:right-auto sm:left-1/2 sm:-translate-x-1/2",
            "top-right": "top-4 sm:left-auto",
            "bottom-left": "bottom-4 sm:right-auto",
            "bottom-center": "bottom-4 sm:right-auto sm:left-1/2 sm:-translate-x-1/2",
            "bottom-right": "bottom-4 sm:left-auto",
          },
        },
        defaultVariants: { position: "bottom-right" },
      },
    );
    
    /** Keyboard focus fans the deck out; a click doesn't (the pointer already has, by hovering) */
    function isFocusVisible(target: EventTarget | null) {
      if (!(target instanceof Element)) return false;
      try {
        return target.matches(":focus-visible");
      } catch {
        return true;
      }
    }
    
    export type ToastViewportProps = ComponentProps<typeof ToastPrimitive.Viewport> & {
      /** The corner, or the middle of the top or bottom edge, where toasts appear */
      position?: ToastPosition;
      /** Always show the toasts as a list, instead of a deck that fans out on hover */
      expand?: boolean;
      /** How many toasts show at once; older ones wait out of sight until newer ones go */
      visibleToasts?: number;
    };
    
    /**
     * Where the toasts appear: a list fixed to a corner of the screen, portalled to the end of the body so no
     * transformed or clipped parent can trap it. Place it once, inside ToastProvider.
     */
    function ToastViewport({
      className,
      position = "bottom-right",
      expand = false,
      visibleToasts = 3,
      style,
      ref,
      ...props
    }: ToastViewportProps) {
      const stack = useContext(StackContext);
      const { configure, setHovered, setFocus } = stack;
      const [list, setList] = useState<HTMLOListElement | null>(null);
      const setRef = useMergedRef(setList, ref);
      const visible = Math.max(1, Math.floor(visibleToasts));
    
      useLayoutEffect(
        () => configure({ position, expand, visibleToasts: visible }),
        [configure, position, expand, visible],
      );
    
      // Native listeners: Radix portals the toasts into this list, so they're its children in the DOM but not
      // in React's tree, and React's own pointer events wouldn't see the pointer reach them
      useEffect(() => {
        if (!list) return;
        const enter = () => setHovered(true);
        const leave = () => setHovered(false);
        const focusIn = (event: FocusEvent) => setFocus(isFocusVisible(event.target) ? "keyboard" : "pointer");
        const focusOut = (event: FocusEvent) => {
          if (!(event.relatedTarget instanceof Node && list.contains(event.relatedTarget))) setFocus("none");
        };
        list.addEventListener("pointerenter", enter);
        list.addEventListener("pointerleave", leave);
        list.addEventListener("focusin", focusIn);
        list.addEventListener("focusout", focusOut);
        return () => {
          list.removeEventListener("pointerenter", enter);
          list.removeEventListener("pointerleave", leave);
          list.removeEventListener("focusin", focusIn);
          list.removeEventListener("focusout", focusOut);
        };
      }, [list, setHovered, setFocus]);
    
      // The list is as tall as the deck or the fanned-out toasts, so the gaps between them count as hovering
      const shown = stack.ids.slice(-visible);
      const front = shown[shown.length - 1];
      const sizes = shown.map((id) => stack.heights[id] ?? 0);
      const height = !front
        ? 0
        : stack.expanded
          ? sizes.reduce((total, size) => total + size, 0) + GAP * (sizes.length - 1)
          : (stack.heights[front] ?? 0) + PEEK * (sizes.length - 1);
    
      return (
        <Portal.Root>
          <ToastPrimitive.Viewport
            ref={setRef}
            data-slot="toast-viewport"
            data-position={position}
            data-expanded={stack.expanded || undefined}
            className={cn(toastViewportVariants({ position }), className)}
            style={{ height, ...style }}
            {...props}
          />
        </Portal.Root>
      );
    }
    
    /* ------------------------------------------------------------------------------------------------------ */
    /* Toast                                                                                                   */
    
    /** A toast's surface, exported for anything that should look like one */
    const toastVariants = cva("rounded-lg border border-border bg-popover text-popover-foreground shadow-lg");
    
    /** What a leaving toast needs to know: swiped away, which edge it goes back to, and reduced motion */
    type Leave = { swiped: boolean; toward: number; reduce: boolean };
    
    const leaveVariants = {
      exit: ({ swiped, toward, reduce }: Leave) =>
        reduce
          ? { opacity: 0, transition: { duration: duration.fast } }
          : swiped
            ? // It's already flying off sideways (onSwipeEnd); it only fades as it goes
              { opacity: 0, filter: blur.hidden, transition: { duration: duration.base, ease: ease.out } }
            : // Back out to the edge it came in from, on a timed curve so it's quickly gone
              {
                opacity: 0,
                filter: blur.hidden,
                y: `${-toward * 100}%`,
                transition: { duration: duration.base, ease: ease.in },
              },
    } satisfies Variants;
    
    const settle = { duration: duration.base, ease: ease.out };
    
    export type ToastProps = Omit<ComponentProps<typeof ToastPrimitive.Root>, "asChild" | "forceMount"> & {
      /** Destructive turns the title (and the icon, in toasts from `toast.error`) the destructive colour */
      variant?: "default" | "destructive";
      /** Called once the toast has finished leaving, for example to drop it from your own list */
      onExitComplete?: () => void;
    };
    
    /**
     * One notification, built on Radix Toast's root. It stays open for the provider's `duration` (or its own),
     * paused while hovered or focused. Use `open` and `onOpenChange` to show it, or leave them out and it opens
     * as it mounts. Escape, a swipe, ToastClose and ToastAction close it.
     */
    function Toast({
      className,
      variant = "default",
      open: openProp,
      defaultOpen = true,
      onOpenChange,
      onExitComplete,
      onSwipeStart,
      onSwipeMove,
      onSwipeCancel,
      onSwipeEnd,
      duration: durationProp,
      style,
      children,
      ...props
    }: ToastProps) {
      // The open state lives here, controlled or not, so the toast can play its exit before Radix unmounts it
      const [own, setOwn] = useState(defaultOpen);
      const open = openProp ?? own;
      const stack = useContext(StackContext);
      const { add, remove, measure, reduce } = stack;
      const id = useId();
      const [box, setBox] = useState<HTMLLIElement | null>(null);
      const [content, setContent] = useState<HTMLDivElement | null>(null);
      // The swipe moves the toast sideways through `x`, separate from its place in the deck (`y`, `scale`)
      const swipeX = useMotionValue(0);
      const [swiped, setSwiped] = useState(false);
      const flight = useRef<AnimationPlaybackControls | null>(null);
      const stay = durationProp ?? stack.duration;
      const remaining = useRef(stay);
    
      // Open toasts join the stack. One that closes leaves it at once, so the rest move up while it slides out.
      useLayoutEffect(() => {
        if (!open) return;
        add(id);
        setSwiped(false);
        swipeX.jump(0);
        return () => remove(id);
      }, [open, id, add, remove, swipeX]);
    
      // Its full height, measured from the content: a tucked toast's own box is the front one's height
      useLayoutEffect(() => {
        if (!open || !box || !content) return;
        const update = () => measure(id, content.offsetHeight + box.offsetHeight - box.clientHeight);
        update();
        const observer = new ResizeObserver(update);
        observer.observe(content);
        return () => observer.disconnect();
      }, [open, box, content, id, measure]);
    
      // Unmounting mid-swipe stops the swipe's spring or flight
      useEffect(() => () => flight.current?.stop(), []);
    
      const setOpen = (next: boolean) => {
        if (openProp === undefined) setOwn(next);
        onOpenChange?.(next);
      };
      // The timer calls the latest close, without restarting whenever the parent renders
      const close = useRef(() => {});
      useLayoutEffect(() => {
        close.current = () => setOpen(false);
      });
    
      // The close timer runs here rather than in Radix. Radix keeps its paused flag in a ref that only its own
      // listeners reset, and they detach with the last toast, so closing one with the mouse could leave the
      // next one paused for good. This one waits on the deck's own hover and focus, and on the window.
      // A new duration (toast.promise settling) or reopening starts the time again.
      // biome-ignore lint/correctness/useExhaustiveDependencies: reopening starts the time again too
      useEffect(() => {
        remaining.current = stay;
      }, [stay, open]);
      // biome-ignore lint/correctness/useExhaustiveDependencies: a new duration starts the timer again
      useEffect(() => {
        if (!open || stack.paused || !Number.isFinite(remaining.current)) return;
        const started = Date.now();
        const timer = setTimeout(() => close.current(), Math.max(0, remaining.current));
        return () => {
          clearTimeout(timer);
          remaining.current -= Date.now() - started;
        };
      }, [open, stack.paused, stay]);
    
      // Its place: 0 is the newest, in front. Older toasts sit behind it (collapsed) or after it (fanned out).
      const edge = stack.position.startsWith("top") ? "top" : "bottom";
      const toward = edge === "bottom" ? -1 : 1; // older toasts go up from the bottom, down from the top
      const order = stack.ids.indexOf(id);
      const index = order < 0 ? 0 : stack.ids.length - 1 - order;
      const front = stack.ids[stack.ids.length - 1];
      const place = Math.min(index, stack.visibleToasts - 1); // past the limit, it waits behind the last one shown
      const hidden = index >= stack.visibleToasts;
      const tucked = !stack.expanded && index > 0;
      const ahead = order < 0 ? [] : stack.ids.slice(order + 1);
      const before = ahead.reduce((total, other) => total + (stack.heights[other] ?? 0), 0);
      const y = toward * (stack.expanded ? before + index * GAP : place * PEEK);
      const scale = stack.expanded ? 1 : 1 - place * SHRINK;
      const height = tucked && front ? stack.heights[front] : stack.heights[id];
      const leave: Leave = { swiped, toward, reduce };
    
      return (
        <AnimatePresence custom={leave} onExitComplete={onExitComplete}>
          {open ? (
            <ToastPrimitive.Root
              key="toast"
              asChild
              open
              // The timer above closes it; Radix's never starts
              duration={Number.POSITIVE_INFINITY}
              onOpenChange={(next) => {
                if (!next) setOpen(false);
              }}
              onSwipeStart={(event) => {
                onSwipeStart?.(event);
                flight.current?.stop();
              }}
              onSwipeMove={(event) => {
                onSwipeMove?.(event);
                // It follows the finger exactly: jump() sets the value without counting it as speed
                if (!event.defaultPrevented) swipeX.jump(event.detail.delta.x);
              }}
              onSwipeCancel={(event) => {
                onSwipeCancel?.(event);
                if (!event.defaultPrevented)
                  flight.current = animate(swipeX, 0, reduce ? { duration: 0 } : spring.snappy);
              }}
              onSwipeEnd={(event) => {
                onSwipeEnd?.(event);
                if (event.defaultPrevented) return;
                setSwiped(true);
                // Past the threshold it carries on the way it was going, off the side of the screen
                if (!reduce)
                  flight.current = animate(swipeX, Math.sign(event.detail.delta.x) * (box?.offsetWidth ?? 0), {
                    duration: duration.base,
                    ease: ease.out,
                  });
              }}
              {...props}
            >
              <motion.li
                ref={setBox}
                data-slot="toast"
                data-variant={variant}
                data-edge={edge}
                data-front={index === 0 || undefined}
                data-tucked={tucked || undefined}
                data-hidden={hidden || undefined}
                inert={hidden || undefined}
                className={cn(
                  toastVariants(),
                  "group/toast absolute inset-x-0 overflow-hidden",
                  "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
                  // It sits at the screen's edge of the list, and scales from the side that shows behind the newest
                  "data-[edge=bottom]:bottom-0 data-[edge=bottom]:origin-top data-[edge=top]:top-0 data-[edge=top]:origin-bottom",
                  "data-hidden:pointer-events-none",
                  className,
                )}
                style={{ x: swipeX, zIndex: stack.ids.length - index, ...style }}
                custom={leave}
                variants={leaveVariants}
                initial={reduce ? { opacity: 0 } : { opacity: 0, filter: blur.hidden, y: `${-toward * 100}%` }}
                animate={{
                  opacity: hidden ? 0 : 1,
                  filter: blur.shown,
                  y,
                  scale,
                  ...(height === undefined ? {} : { height }),
                }}
                exit="exit"
                transition={
                  reduce
                    ? { duration: 0, opacity: { duration: duration.fast } }
                    : {
                        y: spring.snappy,
                        scale: spring.snappy,
                        opacity: settle,
                        filter: settle,
                        height: { duration: duration.slow, ease: ease.out },
                      }
                }
              >
                {/* Tucked behind the newest, a toast's content goes; it comes back through the blur as the deck fans out */}
                <motion.div
                  ref={setContent}
                  data-slot="toast-content"
                  className="flex items-center gap-3 py-3 ps-4 pe-3"
                  initial={false}
                  animate={tucked ? { opacity: 0, filter: blur.hidden } : { opacity: 1, filter: blur.shown }}
                  transition={settle}
                >
                  {children}
                </motion.div>
              </motion.li>
            </ToastPrimitive.Root>
          ) : null}
        </AnimatePresence>
      );
    }
    
    function ToastTitle({ className, ...props }: ComponentProps<typeof ToastPrimitive.Title>) {
      return (
        <ToastPrimitive.Title
          data-slot="toast-title"
          className={cn("text-sm font-medium group-data-[variant=destructive]/toast:text-destructive", className)}
          {...props}
        />
      );
    }
    
    function ToastDescription({ className, ...props }: ComponentProps<typeof ToastPrimitive.Description>) {
      return (
        <ToastPrimitive.Description
          data-slot="toast-description"
          className={cn("text-sm text-muted-foreground", className)}
          {...props}
        />
      );
    }
    
    /**
     * A button that acts on the toast ("Undo"), then closes it. `altText` says how to do the same without the
     * toast, for screen readers, which may hear the toast after it's gone.
     */
    function ToastAction({ className, ...props }: ComponentProps<typeof ToastPrimitive.Action>) {
      return (
        <ToastPrimitive.Action
          data-slot="toast-action"
          className={cn(buttonVariants({ variant: "outline", size: "sm" }), "bg-transparent", className)}
          {...props}
        />
      );
    }
    
    /** Closes the toast. An X named "Close" unless you give it children or another aria-label. */
    function ToastClose({
      className,
      children,
      "aria-label": label = "Close",
      ...props
    }: ComponentProps<typeof ToastPrimitive.Close>) {
      return (
        <ToastPrimitive.Close
          data-slot="toast-close"
          aria-label={label}
          className={cn(
            buttonVariants({ variant: "ghost", size: "icon-sm" }),
            "size-6 rounded-md text-muted-foreground hover:text-foreground",
            className,
          )}
          {...props}
        >
          {children ?? <XIcon aria-hidden className="size-(--size-icon-sm)" />}
        </ToastPrimitive.Close>
      );
    }
    
    /* ------------------------------------------------------------------------------------------------------ */
    /* toast(): a tiny store, read by <Toaster /> with useSyncExternalStore                                     */
    
    type Kind = "default" | "success" | "error" | "loading";
    
    export type ToastOptions = {
      /** Updates the toast with this id, if it's showing, instead of adding another */
      id?: string;
      /** A second line under the title */
      description?: ReactNode;
      /** A button on the toast, such as Undo. It closes the toast after onClick, unless onClick prevents it. */
      action?: { label: string; onClick?: (event: MouseEvent<HTMLButtonElement>) => void; altText?: string };
      /** Milliseconds before it closes; Infinity to keep it until it's dismissed */
      duration?: number;
    };
    
    type ToastRecord = ToastOptions & {
      id: string;
      kind: Kind;
      title: ReactNode;
      open: boolean;
      /** Counts updates, so changed content swaps in */
      version: number;
    };
    
    let records: ToastRecord[] = [];
    let count = 0;
    const listeners = new Set<() => void>();
    const noRecords: ToastRecord[] = [];
    
    function commit(next: ToastRecord[]) {
      records = next;
      for (const listener of listeners) listener();
    }
    
    function subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    }
    
    const getRecords = () => records;
    const getNoRecords = () => noRecords;
    
    function show(kind: Kind, title: ReactNode, options: ToastOptions = {}) {
      const id = options.id ?? `toast-${++count}`;
      const current = records.find((record) => record.id === id);
      commit(
        current
          ? records.map((record) =>
              record === current
                ? { ...record, ...options, id, kind, title, open: true, version: record.version + 1 }
                : record,
            )
          : [...records, { ...options, id, kind, title, open: true, version: 0 }],
      );
      return id;
    }
    
    /** Closes one toast, or every toast without an id. It leaves the store once its exit has played. */
    function dismiss(id?: string) {
      if (!records.some((record) => record.open && (id === undefined || record.id === id))) return;
      commit(
        records.map((record) =>
          record.open && (id === undefined || record.id === id) ? { ...record, open: false } : record,
        ),
      );
    }
    
    function drop(id: string) {
      commit(records.filter((record) => record.id !== id));
    }
    
    type Message<T> = ReactNode | ((value: T) => ReactNode);
    
    function promise<T>(
      work: Promise<T> | (() => Promise<T>),
      messages: { loading: ReactNode; success: Message<T>; error: Message<unknown> },
      options?: Omit<ToastOptions, "id">,
    ) {
      const id = show("loading", messages.loading, options);
      const running = typeof work === "function" ? work() : work;
      running.then(
        (value) =>
          show("success", typeof messages.success === "function" ? messages.success(value) : messages.success, {
            ...options,
            id,
          }),
        (error: unknown) =>
          show("error", typeof messages.error === "function" ? messages.error(error) : messages.error, {
            ...options,
            id,
          }),
      );
      return id;
    }
    
    /**
     * Shows a toast in the Toaster and returns its id. `toast.success`, `toast.error` and `toast.loading` add an
     * icon (loading stays until it's updated or dismissed); `toast.promise` shows loading, then the result, in
     * the same toast; `toast.dismiss(id?)` closes one toast, or all of them.
     */
    const toast = Object.assign((title: ReactNode, options?: ToastOptions) => show("default", title, options), {
      success: (title: ReactNode, options?: ToastOptions) => show("success", title, options),
      error: (title: ReactNode, options?: ToastOptions) => show("error", title, options),
      loading: (title: ReactNode, options?: ToastOptions) => show("loading", title, options),
      promise,
      dismiss,
    });
    
    /** The plain text of a toast's content, for the announcement when it changes */
    function textOf(node: ReactNode): string {
      if (node == null || typeof node === "boolean") return "";
      if (typeof node === "string" || typeof node === "number") return String(node);
      if (Array.isArray(node)) return node.map(textOf).join("");
      if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
      return "";
    }
    
    /** The same check and cross as Loading Button, drawing themselves in (pathLength, a 16px stroke) */
    const CHECK = "M5 12.5l4.5 4.5L19 7.5";
    const CROSS = "M7 7l10 10M17 7L7 17";
    
    function Mark({ d, reduce }: { d: string; reduce: boolean }) {
      return (
        <svg viewBox="0 0 24 24" fill="none" aria-hidden className="size-(--size-icon-md)">
          <motion.path
            d={d}
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={{ pathLength: reduce ? 1 : 0 }}
            animate={{ pathLength: 1 }}
            transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}
          />
        </svg>
      );
    }
    
    const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };
    
    function ToasterToast({ record, onUpdate }: { record: ToastRecord; onUpdate: (text: string) => void }) {
      const reduce = useReducedMotion() ?? false;
      const { kind, version } = record;
    
      // Radix reads a toast out once, as it opens. An update (toast.promise settling) is read out here instead.
      // biome-ignore lint/correctness/useExhaustiveDependencies: only an update is read out
      useEffect(() => {
        if (version > 0) onUpdate([textOf(record.title), textOf(record.description)].filter(Boolean).join(". "));
      }, [version]);
    
      const icon =
        kind === "loading" ? (
          <Spinner aria-hidden />
        ) : kind === "success" ? (
          <Mark d={CHECK} reduce={reduce} />
        ) : kind === "error" ? (
          <span className="flex text-destructive">
            <Mark d={CROSS} reduce={reduce} />
          </span>
        ) : null;
      const motionProps = reduce ? fade : swap;
    
      return (
        <Toast
          open={record.open}
          onOpenChange={(open) => {
            if (!open) dismiss(record.id);
          }}
          onExitComplete={() => drop(record.id)}
          variant={kind === "error" ? "destructive" : "default"}
          duration={kind === "loading" ? Number.POSITIVE_INFINITY : record.duration}
          data-type={kind}
        >
          {/* Updated content swaps in the same cell: the old lifts away blurring as the new rises in */}
          <div className="grid min-w-0 flex-1">
            <AnimatePresence>
              <motion.div
                key={version}
                initial={version === 0 ? false : motionProps.initial}
                animate={motionProps.animate}
                exit={motionProps.exit}
                transition={
                  reduce
                    ? { duration: duration.fast }
                    : { duration: duration.base, ease: ease.out, y: spring.snappy }
                }
                className="col-start-1 row-start-1 flex items-start gap-3"
              >
                {/* The icon sits on the title's first line */}
                {icon ? <span className="flex h-5 shrink-0 items-center">{icon}</span> : null}
                <div className="grid min-w-0 gap-0.5">
                  <ToastTitle>{record.title}</ToastTitle>
                  {record.description ? <ToastDescription>{record.description}</ToastDescription> : null}
                </div>
              </motion.div>
            </AnimatePresence>
          </div>
          {record.action ? (
            <ToastAction altText={record.action.altText ?? record.action.label} onClick={record.action.onClick}>
              {record.action.label}
            </ToastAction>
          ) : null}
          <ToastClose />
        </Toast>
      );
    }
    
    /** Mounted Toasters, in mount order. Only the first shows toasts, so placing two never doubles them. */
    const toasters: ((live: boolean) => void)[] = [];
    
    function syncToasters() {
      toasters.forEach((setLive, index) => {
        setLive(index === 0);
      });
    }
    
    export type ToasterProps = ToastViewportProps & {
      /** Milliseconds each toast stays open, unless it sets its own duration */
      duration?: number;
    };
    
    /**
     * Shows the toasts from `toast()`. Place it once, for example in your root layout; it takes the
     * viewport's props (position, expand, visibleToasts).
     */
    function Toaster({ duration: stay, ...props }: ToasterProps) {
      const items = useSyncExternalStore(subscribe, getRecords, getNoRecords);
      const [live, setLive] = useState(false);
      const [announcement, setAnnouncement] = useState("");
    
      useEffect(() => {
        toasters.push(setLive);
        syncToasters();
        return () => {
          toasters.splice(toasters.indexOf(setLive), 1);
          syncToasters();
        };
      }, []);
    
      // Toasts that closed while no Toaster could play their exit are gone already
      useEffect(() => {
        if (live && records.some((record) => !record.open)) commit(records.filter((record) => record.open));
      }, [live]);
    
      if (!live) return null;
      return (
        <ToastProvider duration={stay}>
          {items.map((record) => (
            <ToasterToast key={record.id} record={record} onUpdate={setAnnouncement} />
          ))}
          <ToastViewport {...props} />
          <span aria-live="polite" className="sr-only">
            {announcement}
          </span>
        </ToastProvider>
      );
    }
    
    export {
      Toast,
      ToastAction,
      ToastClose,
      ToastDescription,
      Toaster,
      ToastProvider,
      ToastTitle,
      ToastViewport,
      toast,
      toastVariants,
      toastViewportVariants,
    };
  6. 6

    Update the import paths to match your project.

Examples

Promise

toast.promise shows a spinner until the work settles, then swaps the same toast to the result through the blur.

Succeeds
Fails
promise.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Toaster, toast } from "@/components/ui/toast";
import { useId } from "react";

/** Pretend work that settles after a moment, one way or the other */
const work = (succeed: boolean) =>
  new Promise<void>((resolve, reject) =>
    setTimeout(() => (succeed ? resolve() : reject(new Error("Failed"))), 1500),
  );

/*
 * toast.promise shows a spinner until the work settles, then swaps the same toast to the result through the
 * blur, and it closes on its own from then.
 */
export default function ToastPromise() {
  const id = useId();

  return (
    <div className="flex flex-wrap items-start justify-center gap-x-6 gap-y-4">
      <div className="flex flex-col items-center gap-1.5">
        <Button
          onClick={() =>
            toast.promise(work(true), {
              loading: "Saving changes…",
              success: "Changes saved",
              error: "Couldn't save changes",
            })
          }
          aria-describedby={`${id}-saves`}
        >
          Save changes
        </Button>
        <span id={`${id}-saves`} className="text-xs text-muted-foreground">
          Succeeds
        </span>
      </div>
      <div className="flex flex-col items-center gap-1.5">
        <Button
          variant="outline"
          onClick={() =>
            toast.promise(work(false), {
              loading: "Publishing…",
              success: "Published",
              error: "Couldn't publish",
            })
          }
          aria-describedby={`${id}-fails`}
        >
          Publish
        </Button>
        <span id={`${id}-fails`} className="text-xs text-muted-foreground">
          Fails
        </span>
      </div>
      <Toaster />
    </div>
  );
}

Composing the parts

For a toast you render yourself: ToastProvider, Toast with your own open state, and ToastViewport placed once. It still stacks and plays its exit.

Friday, 10:00
composed.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Toast,
  ToastAction,
  ToastClose,
  ToastDescription,
  ToastProvider,
  ToastTitle,
  ToastViewport,
} from "@/components/ui/toast";
import { useState } from "react";

/*
 * The parts, for a toast you render yourself: its open state is yours, and it plays its exit when it closes.
 * This one appears at the top centre.
 */
export default function ToastComposed() {
  const [open, setOpen] = useState(false);
  const [scheduled, setScheduled] = useState(true);

  return (
    <ToastProvider>
      <div className="flex flex-col items-center gap-2">
        <Button
          variant="outline"
          onClick={() => {
            setScheduled(true);
            setOpen(true);
          }}
        >
          Schedule meeting
        </Button>
        <span aria-live="polite" className="text-xs text-muted-foreground">
          {scheduled ? "Friday, 10:00" : "Not scheduled"}
        </span>
      </div>
      <Toast open={open} onOpenChange={setOpen}>
        <div className="grid flex-1 gap-0.5">
          <ToastTitle>Meeting scheduled</ToastTitle>
          <ToastDescription>Friday, 10:00 to 11:00</ToastDescription>
        </div>
        <ToastAction altText="Remove the meeting from Friday" onClick={() => setScheduled(false)}>
          Undo
        </ToastAction>
        <ToastClose />
      </Toast>
      <ToastViewport position="top-center" />
    </ToastProvider>
  );
}

API reference

Toaster

Shows the toasts from toast(). Place it once, for example in your root layout; if several mount, only the first shows toasts. Also accepts every ToastViewport prop.

PropTypeDefaultDescription
position?"top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right""bottom-right"Where toasts appear. They swipe towards the nearer side: left for left positions, right otherwise.
duration?number5000Milliseconds each toast stays open, unless it sets its own.
expand?booleanfalseAlways show the toasts as a list, instead of a deck that fans out on hover or focus.
visibleToasts?number3How many toasts show at once. Older ones wait out of sight until newer ones close.

toast

A function, callable from any client code once a Toaster is mounted. Each call returns the toast's id.

PropTypeDefaultDescription
toast(title, options?)?(title: ReactNode, options?: ToastOptions) => string–Shows a toast.
toast.success / toast.error?(title: ReactNode, options?: ToastOptions) => string–With a check, or a cross and the title in the destructive colour. Both draw themselves in.
toast.loading?(title: ReactNode, options?: ToastOptions) => string–With a spinner. It stays until you update it (pass its id) or dismiss it.
toast.promise?(promise, { loading, success, error }, options?) => string–Shows loading until the promise settles, then success or error in the same toast. success and error can be functions of the result.
toast.dismiss?(id?: string) => void–Closes one toast, or every toast without an id.
options.id?string–Updates the toast with this id, if it's showing, instead of adding another.
options.description?ReactNode–A second line under the title.
options.action?{ label: string; onClick?: (event) => void; altText?: string }–A button such as Undo. It closes the toast after onClick, unless onClick calls preventDefault. altText (the label by default) says what it does for screen readers.
options.duration?number–Milliseconds before this toast closes; Infinity keeps it until it's dismissed.

ToastProvider

Wraps your toasts and their viewport, for the parts. Built on Radix Toast's provider.

PropTypeDefaultDescription
duration?number5000Milliseconds a toast stays open, unless it sets its own.
label?string"Notification"Said by screen readers before each toast.
swipeThreshold?number50Pixels a swipe must travel to dismiss the toast; shorter swipes spring back.

Toast

One notification, built on Radix Toast's root. Put a ToastTitle, ToastDescription, ToastAction and ToastClose inside. Escape, a swipe, ToastClose and ToastAction close it; its timer pauses while it's hovered or focused.

PropTypeDefaultDescription
open?boolean–Whether it shows, when you control it.
defaultOpen?booleantrueWhether it shows at first, when you don't.
onOpenChange?(open: boolean) => void–Called when it opens or closes, including when its time is up.
variant?"default" | "destructive""default"Destructive turns the title the destructive colour, on the same neutral surface.
duration?number–Milliseconds before it closes, overriding the provider's.
type?"foreground" | "background""foreground"Foreground (a result of what the person did) is read out at once; background waits its turn.
onExitComplete?() => void–Called once it has finished leaving, for example to drop it from your own list.

ToastViewport

Where the toasts appear: a list fixed to the screen, portalled to the end of the body. Place it once inside ToastProvider. F8 moves focus to it.

PropTypeDefaultDescription
position?ToastPosition"bottom-right"The corner, or the middle of the top or bottom edge.
expand?booleanfalseAlways show the toasts as a list.
visibleToasts?number3How many toasts show at once.
hotkey?string[]["F8"]The keys that move focus to the toasts.
label?string"Notifications ({hotkey})"The region's name for screen readers.

ToastTitle

The toast's first line.

PropTypeDefaultDescription

ToastDescription

A second, muted line.

PropTypeDefaultDescription

ToastAction

A small outline button that acts on the toast, then closes it.

PropTypeDefaultDescription
altTextstring–How to do the same without the toast, for screen readers, which may hear the toast after it's gone.

ToastClose

An X button that closes the toast, named "Close". Pass children to replace the X.

PropTypeDefaultDescription

Related components

  • Loading
    Loading Button

    An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.

  • 64%
    Progress

    An accessible React progress bar and ring for Next.js and Tailwind CSS that show how far along a task is, with an indeterminate state.

  • Stablev2.4.0Beta
    Badge

    An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.