Skip to main content

Command Palette

Search for a command to run...

Styling Sonner Toasts: Advanced Guide

Published
5 min read
Styling Sonner Toasts: Advanced Guide

Introduction

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.

Start with unstyled mode.

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.

The DOM structure matters.

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;
}

Per-variant styling with classNames.

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.

Loading toasts are different.

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;
}

Custom loading spinner.

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.

Action buttons.

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.

Faster animations.

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.

Data attributes for targeting.

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);
}

The full setup.

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;
}

Gotchas.

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.