Styled sonner
Five toast triggers: simple, tone, glass, action and promise. Each renders through the sonner Toaster, is token-driven and sized.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/sonner-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/sonner-styled.tsx"use client"
import * as React from "react"
import { AlertTriangle, CheckCircle, Info, Loader2, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Styled sonner family: 5 self-contained toasters. NO sonner/portal - each export
renders a demo trigger button; clicking it pushes a toast onto a LOCAL stack (a
useState array), it appears in the bottom-right corner (fixed bottom-right, z-50)
and disappears on a timeout or via the close button. Multiple toasts stack on top
of each other. AnimatePresence handles the enter/exit slide+fade; instant under
reduced-motion. Color comes ONLY from tokens, via alpha color-mix.
Deterministic: the ids come from a ref counter (NO Date.now/Math.random). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
export type StyledTone = "success" | "info" | "warning" | "danger"
/* Toast genisligi size'a bagli. */
const toastWidth: Record<StyledSize, string> = {
sm: "w-64",
md: "w-72",
lg: "w-80",
xl: "w-96",
}
/* Tone -> ikon rengi (semantic token). */
const toneIconColor: Record<StyledTone, string> = {
success: "text-success",
info: "text-info",
warning: "text-warning-soft-foreground",
danger: "text-danger",
}
/* Tone -> ikon bileseni. */
const toneIcon: Record<StyledTone, React.ComponentType<{ className?: string }>> = {
success: CheckCircle,
info: Info,
warning: AlertTriangle,
danger: X,
}
const triggerBtn =
"inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-border bg-secondary px-4 text-sm font-medium text-secondary-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const stackClass =
"pointer-events-none fixed bottom-0 right-0 z-50 flex flex-col items-end gap-2 p-4"
const closeBtn =
"inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const toastBase =
"pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-xl border border-border p-4 text-sm shadow-lg [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
/* A slide+fade enter/exit; instant under reduced-motion (opacity only). */
function toastMotion(reduce: boolean | null) {
if (reduce) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.12 },
}
}
return {
initial: { opacity: 0, x: 32, scale: 0.96 },
animate: { opacity: 1, x: 0, scale: 1 },
exit: { opacity: 0, x: 32, scale: 0.96 },
transition: { type: "spring" as const, stiffness: 320, damping: 28 },
}
}
/* ---------------------------------------------------------------- SimpleToaster A plain message toast. */
interface SimpleItem {
id: number
message: React.ReactNode
}
export function SimpleToaster({
className,
size = "md",
label = "Show toast",
}: {
className?: string
size?: StyledSize
label?: React.ReactNode
}) {
const reduce = useReducedMotion()
const [items, setItems] = React.useState<SimpleItem[]>([])
const counter = React.useRef(0)
const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const dismiss = React.useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = React.useCallback(() => {
const id = counter.current++
setItems((prev) => [...prev, { id, message: "Your changes have been saved." }])
const timer = setTimeout(() => dismiss(id), 4000)
timers.current.set(id, timer)
}, [dismiss])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
const m = toastMotion(reduce)
return (
<div data-slot="styled-sonner" className={cn("inline-flex", className)}>
<button type="button" className={triggerBtn} onClick={push}>
{label}
</button>
<div className={stackClass}>
<AnimatePresence initial={false}>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
className={cn(toastBase, "bg-popover text-popover-foreground", toastWidth[size])}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
<div className="min-w-0 flex-1 pt-0.5">{t.message}</div>
<button
type="button"
aria-label="Dismiss"
className={closeBtn}
onClick={() => dismiss(t.id)}
>
<X />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
/* ------------------------------------------------------------------ ToneToaster
success/info/warning/danger tonlarini eslesen token ikonu ile dolasir. */
interface ToneItem {
id: number
tone: StyledTone
message: React.ReactNode
}
const toneCycle: { tone: StyledTone; message: string }[] = [
{ tone: "success", message: "Payment received." },
{ tone: "info", message: "A new update is available." },
{ tone: "warning", message: "Your storage is almost full." },
{ tone: "danger", message: "Could not reach the server." },
]
export function ToneToaster({
className,
size = "md",
label = "Show toast",
}: {
className?: string
size?: StyledSize
label?: React.ReactNode
}) {
const reduce = useReducedMotion()
const [items, setItems] = React.useState<ToneItem[]>([])
const counter = React.useRef(0)
const cursor = React.useRef(0)
const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const dismiss = React.useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = React.useCallback(() => {
const id = counter.current++
const next = toneCycle[cursor.current % toneCycle.length]
cursor.current++
setItems((prev) => [...prev, { id, tone: next.tone, message: next.message }])
const timer = setTimeout(() => dismiss(id), 4000)
timers.current.set(id, timer)
}, [dismiss])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
const m = toastMotion(reduce)
return (
<div data-slot="styled-sonner" className={cn("inline-flex", className)}>
<button type="button" className={triggerBtn} onClick={push}>
{label}
</button>
<div className={stackClass}>
<AnimatePresence initial={false}>
{items.map((t) => {
const Icon = toneIcon[t.tone]
return (
<motion.div
key={t.id}
role="status"
data-tone={t.tone}
className={cn(
toastBase,
"bg-popover text-popover-foreground",
toastWidth[size]
)}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
<Icon className={cn("mt-0.5", toneIconColor[t.tone])} />
<div className="min-w-0 flex-1 pt-0.5">{t.message}</div>
<button
type="button"
aria-label="Dismiss"
className={closeBtn}
onClick={() => dismiss(t.id)}
>
<X />
</button>
</motion.div>
)
})}
</AnimatePresence>
</div>
</div>
)
}
/* ----------------------------------------------------------------- GlassToaster
Buzlu cam toast + backdrop-blur. */
export function GlassToaster({
className,
size = "md",
label = "Show toast",
}: {
className?: string
size?: StyledSize
label?: React.ReactNode
}) {
const reduce = useReducedMotion()
const [items, setItems] = React.useState<SimpleItem[]>([])
const counter = React.useRef(0)
const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const dismiss = React.useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = React.useCallback(() => {
const id = counter.current++
setItems((prev) => [...prev, { id, message: "Frosted and floating." }])
const timer = setTimeout(() => dismiss(id), 4000)
timers.current.set(id, timer)
}, [dismiss])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
const m = toastMotion(reduce)
return (
<div data-slot="styled-sonner" className={cn("inline-flex", className)}>
<button type="button" className={triggerBtn} onClick={push}>
{label}
</button>
<div className={stackClass}>
<AnimatePresence initial={false}>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
className={cn(
toastBase,
"border-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)] bg-[color-mix(in_oklab,var(--color-popover)_70%,transparent)] text-popover-foreground supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-popover)_45%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl",
toastWidth[size]
)}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
<div className="min-w-0 flex-1 pt-0.5">{t.message}</div>
<button
type="button"
aria-label="Dismiss"
className={closeBtn}
onClick={() => dismiss(t.id)}
>
<X />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
/* ---------------------------------------------------------------- ActionToaster
Baslik + aciklama + token aksiyon butonu + kapat. */
interface ActionItem {
id: number
title: React.ReactNode
description: React.ReactNode
}
export function ActionToaster({
className,
size = "md",
label = "Show toast",
}: {
className?: string
size?: StyledSize
label?: React.ReactNode
}) {
const reduce = useReducedMotion()
const [items, setItems] = React.useState<ActionItem[]>([])
const counter = React.useRef(0)
const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const dismiss = React.useCallback((id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = React.useCallback(() => {
const id = counter.current++
setItems((prev) => [
...prev,
{ id, title: "File deleted", description: "report.pdf was moved to trash." },
])
const timer = setTimeout(() => dismiss(id), 6000)
timers.current.set(id, timer)
}, [dismiss])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
const m = toastMotion(reduce)
return (
<div data-slot="styled-sonner" className={cn("inline-flex", className)}>
<button type="button" className={triggerBtn} onClick={push}>
{label}
</button>
<div className={stackClass}>
<AnimatePresence initial={false}>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
className={cn(
toastBase,
"flex-col items-stretch bg-popover text-popover-foreground",
toastWidth[size]
)}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
<button
type="button"
aria-label="Dismiss"
className={cn(closeBtn, "absolute right-2 top-2")}
onClick={() => dismiss(t.id)}
>
<X />
</button>
<div className="pr-6">
<div className="font-medium tracking-tight text-foreground">{t.title}</div>
<div className="mt-0.5 text-muted-foreground">{t.description}</div>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="inline-flex h-8 select-none items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-primary)_88%,var(--color-foreground))] focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => dismiss(t.id)}
>
Undo
</button>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
/* --------------------------------------------------------------- PromiseToaster A loading toast that turns into a success toast after a short delay. */
interface PromiseItem {
id: number
state: "loading" | "success"
}
export function PromiseToaster({
className,
size = "md",
label = "Show toast",
}: {
className?: string
size?: StyledSize
label?: React.ReactNode
}) {
const reduce = useReducedMotion()
const [items, setItems] = React.useState<PromiseItem[]>([])
const counter = React.useRef(0)
const timers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const clearTimer = React.useCallback((id: number) => {
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const dismiss = React.useCallback(
(id: number) => {
setItems((prev) => prev.filter((t) => t.id !== id))
clearTimer(id)
},
[clearTimer]
)
const push = React.useCallback(() => {
const id = counter.current++
setItems((prev) => [...prev, { id, state: "loading" }])
const resolveTimer = setTimeout(() => {
setItems((prev) => prev.map((t) => (t.id === id ? { ...t, state: "success" } : t)))
const dismissTimer = setTimeout(() => dismiss(id), 3000)
timers.current.set(id, dismissTimer)
}, 2000)
timers.current.set(id, resolveTimer)
}, [dismiss])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
const m = toastMotion(reduce)
return (
<div data-slot="styled-sonner" className={cn("inline-flex", className)}>
<button type="button" className={triggerBtn} onClick={push}>
{label}
</button>
<div className={stackClass}>
<AnimatePresence initial={false}>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
data-state={t.state}
className={cn(toastBase, "bg-popover text-popover-foreground", toastWidth[size])}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
{t.state === "loading" ? (
<Loader2 className="mt-0.5 animate-spin text-muted-foreground" />
) : (
<CheckCircle className={cn("mt-0.5", toneIconColor.success)} />
)}
<div className="min-w-0 flex-1 pt-0.5">
{t.state === "loading" ? "Uploading your file..." : "Upload complete."}
</div>
<button
type="button"
aria-label="Dismiss"
className={closeBtn}
onClick={() => dismiss(t.id)}
>
<X />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Simple
A single button that fires a plain token toast.
import { SimpleToaster } from "@/components/ui/sonner-styled"
<SimpleToaster />Tone
Success, warning and danger toasts on semantic tones.
import { ToneToaster } from "@/components/ui/sonner-styled"
<ToneToaster />Glass
A frosted glass toast surface.
import { GlassToaster } from "@/components/ui/sonner-styled"
<GlassToaster />Action
A toast with a title, description and an action button.
import { ActionToaster } from "@/components/ui/sonner-styled"
<ActionToaster />Promise
A loading toast that resolves to success.
import { PromiseToaster } from "@/components/ui/sonner-styled"
<PromiseToaster />ai2 Styled sonner: 5 styled variations on the token system
The ai2 Styled sonner are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around toast notification triggers. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: sonner animates each toast in and out. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, toasts appear and dismiss without sliding.
What is in the ai2 Styled sonner?
5 exports in one file: Simple, Tone, Glass, Action and Promise. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: sonner animates each toast in and out.
- Reduced-motion aware: Under prefers-reduced-motion, toasts appear and dismiss without sliding.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled sonner sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.