Styling Sonner Toasts: Advanced Guide

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

Sonner is the best toast library for React. But the docs barely cover advanced styling. This is everything I learned building a custom dark toast system with colored rails, action buttons, and fast animations.
Set unstyled: true on toastOptions. This strips every default style. You own everything.
<Toaster
position="top-right"
visibleToasts={5}
gap={8}
toastOptions={{
unstyled: true,
classNames: {
toast:
"flex items-center gap-3 w-[356px] rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3.5 shadow-lg",
title: "text-[13px] font-semibold text-zinc-100",
description: "text-[11px] text-zinc-500",
},
}}
/>
The toast element is an li. It becomes your flex container. Every child sits inside it as a flat sibling.
Sonner renders this inside each toast.
li[data-sonner-toast]
div[data-icon] <- variant icon
div[data-content] <- title + description wrapper
button[data-cancel] <- cancel button (if provided)
button[data-action] <- action button (if provided)
No wrapper around the buttons. They are direct children of the li. This means ml-auto on the first button would work. But it is cleaner to set flex: 1 on [data-content] so the text area fills remaining space and pushes buttons right.
[data-sonner-toaster] [data-sonner-toast] [data-content] {
flex: 1;
min-width: 0;
}
The classNames object on toastOptions accepts success, error, and info keys. These classes get added to the toast li alongside the base toast class.
classNames: {
toast: 'flex items-center gap-3 rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3.5',
success: 'border-l-[3px] !border-l-green-500 [&_[data-icon]]:text-green-500',
error: 'border-l-[3px] !border-l-red-500 [&_[data-icon]]:text-red-500',
info: 'border-l-[3px] !border-l-blue-500 [&_[data-icon]]:text-blue-500',
}
The !border-l-* needs !important because the base border class sets all sides. The [&_[data-icon]] selector targets the icon wrapper to color Sonner's built-in SVG icons.
toast.loading() does not accept a variant. It renders a spinner inside div[data-icon] > .sonner-loader. The loader has position: absolute and left: 50% by default. This centers it in the toast instead of sitting on the left.
Fix it with CSS.
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] {
border-left: 3px solid #3b82f6;
}
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] [data-icon] {
color: #3b82f6;
}
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] .sonner-loader {
position: static;
transform: none;
}
Sonner's default spinner is the iOS-style radial loader. You can replace it globally.
function LoadingSpinner() {
return (
<svg
className="animate-spin"
fill="none"
height="18"
viewBox="0 0 18 18"
width="18"
>
<circle cx="9" cy="9" r="7" stroke="#262626" strokeWidth="2" />
<path
d="M9 2a7 7 0 0 1 7 7"
stroke="#3B82F6"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
}
<Toaster icons={{ loading: <LoadingSpinner /> }} />;
A dark track circle with a blue arc that rotates. Looks clean on dark backgrounds.
Sonner supports action and cancel on individual toasts.
toast.error("Upload failed", {
description: "Could not reach the server.",
cancel: { label: "Dismiss", onClick: () => {} },
action: { label: "Retry", onClick: () => handleRetry() },
});
Style them through classNames on the Toaster.
classNames: {
actionButton: 'text-[11px] font-semibold px-3 py-1.5 rounded-md bg-red-500/15 text-red-400',
cancelButton: 'text-[11px] font-medium text-zinc-500 hover:text-zinc-300 transition-colors',
}
The cancel button renders before the action button in the DOM. Both are flat children of the toast li. No wrapper div.
Sonner's default entrance animation is 400ms. Override it with a CSS variable.
[data-sonner-toaster] [data-sonner-toast] {
--toast-animation-duration: 200ms;
}
The stack expand and collapse also uses 400ms transitions. Override those too.
[data-sonner-toaster] [data-sonner-toast] {
--toast-animation-duration: 200ms;
transition:
transform 200ms ease,
opacity 200ms ease,
height 200ms ease,
box-shadow 200ms ease !important;
}
The !important is needed to override Sonner's inline transition styles.
Every toast exposes data attributes you can use for styling.
data-type="success|error|info|loading"
data-expanded="true|false"
data-front="true|false"
data-visible="true|false"
data-mounted="true|false"
data-x-position="left|center|right"
data-y-position="top|bottom"
Example. Style only the front toast differently.
[data-sonner-toast][data-front="true"] {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
Here is a complete dark theme Toaster with colored left rails, custom spinner, styled action buttons, and fast animations.
<Toaster
position="top-right"
visibleToasts={5}
gap={8}
icons={{ loading: <LoadingSpinner /> }}
toastOptions={{
unstyled: true,
classNames: {
toast:
"flex items-center gap-3 w-[356px] rounded-xl border border-[#262626] bg-[#1A1A1A] px-4 py-3.5 shadow-lg",
title: "text-[13px] font-semibold text-[#F0F0F0]",
description: "text-[11px] text-[#636363]",
actionButton:
"text-[11px] font-semibold px-3 py-1.5 rounded-md bg-red-500/15 text-red-400 hover:bg-red-500/25 transition-colors",
cancelButton:
"text-[11px] font-medium text-[#636363] hover:text-[#999] transition-colors",
closeButton: "text-[#636363] hover:text-[#999] transition-colors",
success:
"border-l-[3px] !border-l-green-500 [&_[data-icon]]:text-green-500",
info: "border-l-[3px] !border-l-blue-500 [&_[data-icon]]:text-blue-500",
error: "border-l-[3px] !border-l-red-500 [&_[data-icon]]:text-red-500",
},
}}
/>
[data-sonner-toaster] [data-sonner-toast] [data-content] {
flex: 1;
min-width: 0;
}
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] {
border-left: 3px solid #3b82f6;
}
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] [data-icon] {
color: #3b82f6;
}
[data-sonner-toaster] [data-sonner-toast][data-type="loading"] .sonner-loader {
position: static;
transform: none;
}
[data-sonner-toaster] [data-sonner-toast] {
--toast-animation-duration: 200ms;
transition:
transform 200ms ease,
opacity 200ms ease,
height 200ms ease,
box-shadow 200ms ease !important;
}
The loading spinner has position: absolute by default. You must override it to static or it floats to the center of the toast.
The unstyled mode strips everything. Including flex layout on the content wrapper. You need the [data-content] { flex: 1 } CSS rule or buttons will not push to the right.
Action button colors are global through classNames. If you need different colors per variant you have to use the variant class selector. For example [&_[data-action]]:bg-green-500/15 inside the success className.
The !important on border-l-* is required. The base border shorthand in the toast class sets border-left which overrides your variant border without it.
visibleToasts controls how many toasts exist in the DOM. Not just how many show in the collapsed stack. Set it higher than 3 if you want users to see all toasts when they hover to expand.