# Toast

> A floating notification you can swipe away: it follows the finger and only leaves on release.

Feedback · [Docs](https://ui.ssntl.app/docs/components/toast) · `npx shadcn@latest add @ssntl/toast`

Installs `components/ui/toast.tsx`, with `utils`, `close-button`, `elevation`. Read this file before you use, change or review it.

## When to use it

- To confirm an action that just happened, with one `follow-up` such as Undo.

## When not to

- For errors that need a fix, or anything people must read: use Notice or a Field hint.

## Usage

```tsx
import {
  Toast,
  ToastAction,
  ToastDescription,
  ToastProvider,
  ToastTitle,
  ToastViewport,
} from "@/components/ui/toast"

;<ToastProvider swipeDirection="right">
  <Toast open={open} onOpenChange={setOpen}>
    <ToastTitle>Order archived</ToastTitle>
    <ToastDescription>It moved to Archive with its history.</ToastDescription>
    <ToastAction altText="Open Archive to restore it">Undo</ToastAction>
  </Toast>
  <ToastViewport />
</ToastProvider>
```

## Rules for Toast

- **Floating notification.** Elevation-4, above the page and its menus; announced politely, paused while hovered or focused, reachable with F8.
- **Responsive gesture.** The toast follows the finger 1:1 from the first pixel.
- **Continuous gesture.** The pointer stays captured even when it leaves the toast.
- **Cancelable gesture.** It dismisses on release past the threshold; letting go short of it springs back.
- One action at most, with `altText` for people who can't reach the toast in time.
- With an action or an error it stays until dismissed (duration={Infinity}); a plain confirmation lasts at least 5 seconds.
- The close button reads “Dismiss notification” by default; change `closeLabel` in other languages.

## Keyboard

| Key    | Action                               |
| ------ | ------------------------------------ |
| F8     | Moves focus to the toast.            |
| Tab    | Reaches its action and close button. |
| Escape | Dismisses it.                        |

## How it is built

A floating notification (Radix Toast): announced politely, paused while hovered or focused, and reachable with F8. Floating notification: elevation 4, above the page and its menus. Gestures: swipe right to dismiss. The toast follows the finger 1:1 from the first pixel (responsive), keeps the pointer even off the toast (continuous) and only dismisses on release past the threshold; letting go short of it springs back (cancelable).

## API notes

- `closeLabel`: The close button's accessible name.
- `ToastAction`: One follow-up action, such as Undo; altText says how to do it otherwise.

## UI foundations it applies

- **Close buttons** (Touch & pointer targets): Give a close button a 48px target, 8px from the container's edge.
- **Feedback without distraction** (Animation principles): Confirm with a small, brief signal that gets out of the way.
- **Responsive gesture** (Interaction details): Track a drag 1:1 from the first pixel.
- **Cancelable gesture** (Interaction details): Commit a gesture on release, so it can still be undone mid-drag.
- **Continuous gesture** (Interaction details): Keep capturing the pointer for a drag until release.
- **Floating notification** (Layering & elevation): Put toasts on the top layer, above everything on the page.

## Overlays

- Focus moves into the layer, stays trapped while it is modal, and returns to the trigger on close.
- Escape closes it; so does a visible close button named for what it closes (Close dialog, Dismiss notification), never “X”.
- **Contain its scroll (overscroll-behavior.** Contain), so scrolling inside never scrolls the page behind.
- A modal is never taller than the screen (100dvh): long content scrolls inside while the title and the actions stay in view.
- A toast with an action or an error stays until it is dismissed; a timed toast lasts at least 5 seconds and pauses on hover and focus. Anything that moves on its own for more than 5 seconds has a pause control.
- **Elevation matches the layer.** Dropdowns `elevation-2`, raised cards `elevation-3`, toasts and modals `elevation-4` (a modal adds a scrim).
- Floating layers enter with `pop-in` from their origin and leave faster with `pop-out`.

## Motion

- Animate only to explain a change; the interface is complete without motion.
- **Durations from tokens.** `--duration-fast` 150 (press, hover), `--duration-pop` 180 (tooltips, icon swaps), `--duration-menu` 220 (menus, small moves), `--duration-panel` 320 (modals, sheets).
- **Easing from tokens.** `--ease-out` for entrances, exits and presses; `--ease-in-out` for things already on screen moving to a new place; linear for progress only.
- Use transitions for interactions so they can be interrupted; keyframes only for one-off sequences.
- Name the properties you transition (transition-[scale,opacity]); never transition: all.
- **Don't animate on page load.** Content that is already there appears at once; entrance motion is for things that arrive later.
- No custom animation on high-frequency interactions (typing, toggling in a list, hovering a table, keyboard shortcuts, a command palette); the cost repeats every time.
- Never `ease-in` on interface motion: it starts slow, exactly when the person is watching.
- **Never animate from scale(0).** Start at 0.9–0.97 with opacity 0 (`pop-in` starts at 0.96). The one exception is a cross-fading icon (`icon-swap`), which starts at 0.25 behind a blur.
- Popovers and menus grow from their trigger (transform-origin at the trigger); modals stay centered.
- Keep animated blur under 20px; a 2–4px blur masks an imperfect cross-fade.
- `will-change` only once you see first-frame stutter, naming the property that animates (scale, not transform), and never on an element that doesn't animate; it also makes the element the container of fixed descendants.
- Entrances stagger 100ms apart (80ms per word in a headline): each chunk rises 12px out of a 4px blur over `--duration-panel`, and exits travel less, faster.
- Icon cross-fades run on `--duration-pop` with cubic-bezier(0.2, 0, 0, 1), and only for infrequent swaps; a tab's icon or an action revealed on hover swaps instantly. (better-ui suggests 300ms; the system keeps its 180ms token, under the 300ms limit for interface motion.)
- Exits are quicker and travel less than entrances; animate opacity, translate, scale and filter, never layout properties.
- Under reduced motion keep opacity and color only. No state lives in motion alone.

## Gestures

- A dragged element follows the pointer 1:1 from the first pixel.
- Capture the pointer for the whole drag, so straying off the element keeps it alive.
- Commit on release past a threshold, or on momentum: a quick flick (average speed above about 0.11px/ms) is enough. Letting go short of both springs back.
- Past a natural edge, add friction that grows the further you pull, never a hard stop.
- Ignore a second pointer while a drag is in progress, so the element never jumps.
- Show what releasing will do before it happens (anticipation).
- Every gesture has a button or key that does the same thing.

## Interaction

- Every control has an accessible name: its visible label, or `aria-label` for icon-only controls.
- Every keyboard-reachable control shows a visible focus ring (2px, offset 2px, the ring token).
- Anything you can do with a pointer you can do with the keyboard.
- Targets are 48px (44px minimum for dense rows), with at least 8px between neighbours (the system's density); the hit area covers the whole visible control.
- Press feedback is 0.96 scale in 150ms (`--es-press`, `--duration-fast`); never below 0.96, never slow, and a disabled control never scales.
- One primary action per view; past three secondary actions, the rest go in a menu.
- Hover styles only on pointer devices; never hide a control's function behind hover.
- **Undo beats confirmation.** A frequent, reversible action runs at once and offers Undo (Order archived. Undo). Confirm only what can't be undone, affects other people or destroys many items. A destructive action is red text, placed last.
- Never disable a submit button until the form is valid; validate on submit. While pending, use `aria-disabled="true"` with a spinner beside the label, since disabled drops focus.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Notice](notice.md): A dismissible message with a full-size close button and concentric corners.
- [Dialog](dialog.md): A modal on the top layer with focus trap, a nested close button and actions at the end.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
