Working With Sonner
<!-- CORE-OVERLAY-START -->
This repository (Asymmetric-al/core)
Core already ships a Sonner toaster through @asym/ui. Use this skill to
operate that existing setup, not to mount a second toaster. Reconcile this
overlay after upstream refreshes before running bun run skills:sync.
Triggers
- Toast copy, variants, promise/loading toasts, positioning, or Sonner API
questions in this repo.
- Do not install a second
sonner tree or add another <Toaster />.
Workflow
- Reuse
packages/ui/components/shadcn/sonner.tsx and the @asym/ui /
@asym/ui/components/shadcn re-exports.
- Import
toast from sonner the same way existing UI files do.
- Keep theme, tokens, and layout ownership in Core's design system.
- After overlay or toaster-host refreshes, run
bun run skills:sync and
bun run skills:verify.
Checklist
<!-- CORE-OVERLAY-END -->
A guide skill for Sonner, the toast library. When a task involves Sonner — wiring it up, rendering toasts, styling them, or fixing them — answer from this file first. Full prop tables for <Toaster /> and toast() live in API.md; read it when you need an exact prop name, type, or default.
Setup
Two pieces, and only two:
- Do not mount a Toaster. Core layouts already mount
@asym/ui's <Toaster /> once. Reuse that host. A second mounted Toaster duplicates every toast.
toast() called from client code — event handlers, effects, callbacks. It's a plain function, no hook or provider needed, but it does nothing on the server: in a server action, return the result and call toast() in the client code that receives it.
import { Toaster } from "@asym/ui/components/shadcn/sonner"; // already mounted in Core layouts
import { toast } from "sonner"; // anywhere client-side
Do not import <Toaster /> from sonner and do not mount a second toaster.
Picking the right call
| You want | Call |
|---|
| Plain message | toast('Title') — add { description } for a second line |
| Success / error / info / warning icon | toast.success('…'), toast.error('…'), etc. |
| Spinner while you manage state yourself | toast.loading('…'), then update it by id |
| Loading → success/error tied to a promise | toast.promise(promise, { loading, success, error }) — success/error accept functions receiving the resolved value/error |
| Button that does something | { action: { label, onClick } } — closes the toast unless onClick calls event.preventDefault(); cancel is the secondary variant |
| Custom JSX, default toast shell | toast(<jsx />) |
| Custom JSX, no styles at all | toast.custom((t) => <jsx />) — headless, t gives you the id to dismiss |
Recipes
Update a toast — call toast() again with the same id; only the props you pass change. Switching to toast.success(…, { id }) changes the type. This is how loading → success flows work without toast.promise:
const id = toast.loading("Uploading…");
toast.success("Uploaded", { id });
Persist — { duration: Infinity }. Dismiss — toast.dismiss(id), or toast.dismiss() for all. Read active toasts — useSonner() in React, toast.getToasts() outside it.
Links or components in the text — pass a function for the title or description: toast(() => <a href="…">View</a>).
One toaster — Core mounts a single @asym/ui <Toaster />. Do not add a second host.
Close callbacks — onDismiss fires on close button or swipe; onAutoClose fires on timeout. They are separate; there is no single "closed" callback.
Styling — the escalation ladder
Climb only as far as the change requires; jumping to the top rung too early is fine (it's the recommended end state), lingering in the middle is not.
- Defaults — plus
richColors on the Toaster for colorful success/error, invert to flip against the theme.
- Inline tweaks —
toastOptions={{ style: {…} }} on the Toaster for all toasts, or style per toast() call.
- Shared toast chrome — change the shared Sonner host in
packages/ui/components/shadcn/sonner.tsx and packages/ui/styles/globals.css. Do not apply raw Tailwind colors such as a forced red on individual toasts. Use toast.success / toast.error on the existing host.
- Headless —
toast.custom() with your own JSX, keeping Sonner's positioning, stacking, and swipe. The recommended approach for a design-system toast: wrap it in your own toast() abstraction. (unstyled: true exists as a halfway house, but headless gives more control for the same effort.)
Icons — swap defaults per-type with the Toaster's icons prop, per-toast with icon, remove with null.
Theme — theme defaults to 'light' and does not track the OS. Pass theme="system", or wire your theme provider: <Toaster theme={resolvedTheme} /> from next-themes.
Troubleshooting
| Symptom | Cause → fix |
|---|
| Toast never appears | Core already mounts @asym/ui's <Toaster />. Reuse that host — do not add another. If it unmounted (conditional render, per-page placement), restore the shared layout toaster. If calling from a server action: toast() is client-only — call it with the action's result on the client. |
| Same toast appears twice | Two Toasters mounted (layout and page) — keep one. Or toast() fired in an effect under React StrictMode's dev double-invoke — fire from the event handler instead, or pass a stable id so the second call updates rather than duplicates. |
| Tailwind/CSS classes have no effect | Default styles override them. Mark them !important, or use unstyled / headless (see the ladder above). |
| Toasts render completely unstyled (common in Astro, view transitions) | Sonner's injected stylesheet was lost — import it explicitly in a layout: import 'sonner/dist/styles.css'. |
| Unstyled inside Shadow DOM | Styles land in document.head, not the shadow root. Copy the style tag whose text includes [data-sonner-toaster] into the shadow root. |
| Toast behind a modal/overlay, or clipped | An ancestor creates a stacking context (transform, filter, overflow) or the overlay out-z-indexes the toaster. Move <Toaster /> to the document root, outside any dialog/portal container. |
| Dark mode ignored | theme defaults to 'light' — set theme="system" or pass the resolved theme (see Theme above). |
| Success/error look gray, not green/red | That's the default. Add richColors to the Toaster. |
| Toast never closes | duration: Infinity, dismissible: false, or a toast.promise whose promise never settles — the loading toast waits forever. |
toast.promise stuck on loading | It needs a promise (or a function returning one) as its first argument, and the promise must actually resolve/reject. |
| Swipe-to-dismiss goes the wrong way / doesn't work | Directions derive from position. Override with swipeDirections on the Toaster. |
| Toast shows up in every toaster | Core has one shared toaster. Do not mount a second host. |
| Toasts too close to the screen edge on mobile | offset (desktop, default 32px) and mobileOffset (<600px, default 16px) — numbers, CSS strings, or per-side objects. |