Menus & FeedbackToast

The notification surface — swipe to dismiss, arrives with the one interrupting sound.

Preview

    The thinking

    A toast is the lowest-ranking thing on screen, and I wanted every mechanism here to encode that rank instead of fighting it. The load-bearing decision is that the sound belongs to the toast's arrival, not to the press that caused it: the Toast root fires notify once on mount, guarded by an announced ref so a re-render never double-plays it, while the trigger keeps its own ordinary tap. You acted, then something happened. The same rank shows in the motion — animate-pop-in on open because a notification should announce itself once, animate-overlay-out on close because dismissal should be quiet — and in what the component refuses to do: no blocking, no confirmation step, nothing that makes the least important surface on screen behave like the most. Dismissing one should never cost a decision.

    Interface design

    There is no toast color. The surface wears the card tokens — bg-card, border-border, text-card-foreground on a rounded-lg border — because a notification is a small card that happens to move, and minting a special token for it would rank it above the content it interrupts. Hierarchy inside runs on one size: title and description are both text-sm, split by font-medium against text-muted-foreground, so the toast never speaks in larger type than the page. The padding is p-4 pr-8 — the extra right side is reserved room for the absolutely-positioned close, so a long title can't run under the X. The action is the real outline button from buttonVariants at size="sm", not a lookalike, so when I retune the button the toast follows for free. The viewport pins the stack bottom-0 right-0, caps it at max-w-sm, and separates arrivals with gap-2.

    Interaction

    Entry is animate-pop-in — 200ms of ease-swift, scale 0.96 to 1 — and every exit is animate-overlay-out at 150ms, because in my book a surface has to get out of the way faster than it arrived. The swipe is where the intent reading lives. While the finger is down the toast makes no guesses: it tracks the pointer 1:1 through --radix-toast-swipe-move-x with transitions switched off, so nothing fights the hand. Release is the question. Under Radix's threshold, data-[swipe=cancel] eases it back over 200ms of ease-swift — a short drag reads as a changed mind, not a command — and past it, data-[swipe=end] commits to the same 150ms fade. The default swipeDirection is right, toward the viewport edge the stack hangs off, so the dismissal gesture throws the toast out the way it came in. Interruption is this cheap on purpose: a notification is the lowest-ranking thing on screen, and dismissing one should cost a flick, not a decision.

    Sound design

    Notify is the one sound in the palette I let interrupt — a full triad strike, still quiet at the 0.3 ceiling — and it belongs to the toast's arrival, not the button press. The toast plays it once on mount through useSound; the trigger still taps through the global layer's capturing click listener, and stacking the two tells the truth: you acted, then something happened. The action button inside carries no data-silent — it's a real button and its press is a real tap. The hook even covers the mount-time edge: a play requested against a still-loading element queues, so the arrival lands when the file does instead of silently no-op'ing.

    Empathy

    Under reduced motion the toast drops everything — motion-reduce:animate-none motion-reduce:transition-none — so it appears in place and leaves in place; the arrival is still announced, just not performed. The close is an icon-only X, so it carries aria-label="Dismiss" and answers the keyboard with focus-visible:ring-1 on the ring token while a mouse click stays unmarked. The action is where Radix is stricter than I am: ToastAction won't compile without altText — the demo passes undo saving the draft — because a screen reader user may never reach a surface that dismisses itself, and the alternative has to travel with the announcement. Long content wraps inside overflow-hidden while the action holds its width with shrink-0; nothing truncates. There is no disabled state, and that's honest — a notification you can't dismiss isn't disabled, it's hostile.
    toast.tsx
    "use client";
    
    import * as React from "react";
    import * as ToastPrimitive from "@radix-ui/react-toast";
    import { X } from "@phosphor-icons/react/dist/ssr";
    
    import { useSound } from "@/hooks/useSound";
    import { SOUNDS } from "@/lib/sounds";
    import { cn } from "@/lib/utils";
    import { buttonVariants } from "../button/button";
    
    const ToastProvider = ({
    	// swipe right, toward the viewport edge the stack hangs off — the
    	// dismissal gesture throws the toast out the way it came in
    	swipeDirection = "right",
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Provider>) => (
    	<ToastPrimitive.Provider swipeDirection={swipeDirection} {...props} />
    );
    ToastProvider.displayName = ToastPrimitive.Provider.displayName;
    
    const ToastViewport = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Viewport>>;
    }) => (
    	<ToastPrimitive.Viewport
    		ref={ref}
    		className={cn(
    			"fixed bottom-0 right-0 z-50 flex w-full max-w-sm flex-col gap-2 p-4 outline-none",
    			className
    		)}
    		{...props}
    	/>
    );
    ToastViewport.displayName = ToastPrimitive.Viewport.displayName;
    
    const Toast = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Root>>;
    }) => {
    	// the toast owns notify, played on arrival — not on the button that
    	// caused it. the press already tapped through the layer; arrival is a
    	// second event and the second sound tells that truth.
    	const [playNotify] = useSound(SOUNDS.notify);
    	const announced = React.useRef(false);
    	React.useEffect(() => {
    		if (announced.current) return;
    		announced.current = true;
    		playNotify();
    	}, [playNotify]);
    
    	return (
    		<ToastPrimitive.Root
    			ref={ref}
    			className={cn(
    				"relative flex w-full items-center gap-3 overflow-hidden rounded-lg border border-border bg-card p-4 pr-8 text-card-foreground",
    				// enter pops; programmatic close fades
    				"data-[state=open]:animate-pop-in data-[state=closed]:animate-overlay-out",
    				// while swiping, track the finger 1:1 through the radix var —
    				// no transition fighting the pointer
    				"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none",
    				// under the threshold it springs back; past it, it fades out
    				"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-transform data-[swipe=cancel]:duration-200 data-[swipe=cancel]:ease-swift",
    				"data-[swipe=end]:animate-overlay-out",
    				"motion-reduce:animate-none motion-reduce:transition-none",
    				className
    			)}
    			{...props}
    		/>
    	);
    };
    Toast.displayName = ToastPrimitive.Root.displayName;
    
    const ToastTitle = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Title>>;
    }) => (
    	<ToastPrimitive.Title
    		ref={ref}
    		className={cn("text-sm font-medium", className)}
    		{...props}
    	/>
    );
    ToastTitle.displayName = ToastPrimitive.Title.displayName;
    
    const ToastDescription = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Description>>;
    }) => (
    	<ToastPrimitive.Description
    		ref={ref}
    		className={cn("text-sm text-muted-foreground", className)}
    		{...props}
    	/>
    );
    ToastDescription.displayName = ToastPrimitive.Description.displayName;
    
    // a real action, so it taps through the layer like any button — no
    // data-silent here; the tap is the press, notify was the arrival.
    // it wears the real outline button, not a lookalike: when button.tsx
    // retunes hover or focus, toast actions follow for free
    const ToastAction = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Action> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Action>>;
    }) => (
    	<ToastPrimitive.Action
    		ref={ref}
    		className={cn(
    			buttonVariants({ variant: "outline", size: "sm" }),
    			"shrink-0",
    			className
    		)}
    		{...props}
    	/>
    );
    ToastAction.displayName = ToastPrimitive.Action.displayName;
    
    const ToastClose = ({
    	ref,
    	className,
    	...props
    }: React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close> & {
    	ref?: React.Ref<React.ComponentRef<typeof ToastPrimitive.Close>>;
    }) => (
    	<ToastPrimitive.Close
    		ref={ref}
    		className={cn(
    			"absolute right-2 top-2 rounded-sm p-1 text-muted-foreground transition-colors duration-150 ease-swift hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none",
    			className
    		)}
    		aria-label="Dismiss"
    		{...props}
    	>
    		<X className="h-4 w-4" />
    	</ToastPrimitive.Close>
    );
    ToastClose.displayName = ToastPrimitive.Close.displayName;
    
    export {
    	ToastProvider,
    	ToastViewport,
    	Toast,
    	ToastTitle,
    	ToastDescription,
    	ToastAction,
    	ToastClose,
    };