Progress toasters
Five toast triggers that show how long is left before the toast dismisses itself: a bottom bar, a ring in the icon slot, a numeric countdown, a sweeping tint and a draining edge rule. The indicator is locked to one fixed timeout constant. Each is self-contained (no sonner package), sized and token-driven.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/sonner-progressDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/sonner-progress.tsx"use client"
import * as React from "react"
import { AlertTriangle, CheckCircle, Info, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Styled sonner - progress family: 5 self-contained toasters. NO sonner package and
NO portal - each export renders a demo trigger button; clicking it pushes a toast
onto a LOCAL stack (a useState array), and it appears in the bottom-right corner
(fixed bottom-right, z-50). The difference: an indicator showing the time left
until auto-dismiss (a bottom bar, a ring, a countdown, a sweep, an edge line). The
duration is a FIXED constant (TOAST_MS) - the indicator is locked to it, making it
deterministic (NO Date.now/Math.random).
Under reduced-motion the indicator animation is skipped (it stays static) but the
toast still auto-dismisses. Color comes ONLY from tokens, via alpha color-mix. */
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,
}
/* Tone -> gosterge dolgu rengi (semantic token). */
const toneFill: Record<StyledTone, string> = {
success: "bg-success",
info: "bg-info",
warning: "bg-warning",
danger: "bg-danger",
}
/* Tone -> sweep katmani (token uzerinden color-mix alpha). */
const toneSweep: Record<StyledTone, string> = {
success: "bg-[color-mix(in_oklab,var(--color-success)_14%,transparent)]",
info: "bg-[color-mix(in_oklab,var(--color-info)_14%,transparent)]",
warning: "bg-[color-mix(in_oklab,var(--color-warning)_14%,transparent)]",
danger: "bg-[color-mix(in_oklab,var(--color-danger)_14%,transparent)]",
}
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 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 bg-popover p-4 text-sm text-popover-foreground shadow-lg [&_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"
/* The auto-dismiss duration (fixed). The indicator is locked to exactly this
duration. */
const TOAST_MS = 5000
const TOAST_SEC = TOAST_MS / 1000
/* 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 },
}
}
interface ToastItem {
id: number
message: string
}
/* Shared stack logic: ref-counted id, fixed timeout, every timer cleared on
unmount (no setState-after-unmount). */
function useToastStack(message: string) {
const [items, setItems] = React.useState<ToastItem[]>([])
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 }])
const timer = setTimeout(() => dismiss(id), TOAST_MS)
timers.current.set(id, timer)
}, [dismiss, message])
React.useEffect(() => {
const map = timers.current
return () => {
map.forEach((t) => clearTimeout(t))
map.clear()
}
}, [])
return { items, push, dismiss }
}
interface ProgressToasterProps {
className?: string
size?: StyledSize
tone?: StyledTone
label?: React.ReactNode
}
/* Toast body: every variant is identical apart from the indicator. The indicator and overlay slots carry the variant-specific visual. */
function ProgressShell({
message,
className,
size,
tone,
label,
indicator,
overlay,
leading,
}: Required<Pick<ProgressToasterProps, "size" | "tone">> & {
message: string
className?: string
label?: React.ReactNode
/* The indicator added below or beside the toast. */
indicator?: React.ReactNode
/* Toast yuzeyini kaplayan gosterge (sweep). */
overlay?: React.ReactNode
/* The indicator standing in for the icon (a ring/countdown). */
leading?: React.ReactNode
}) {
const reduce = useReducedMotion()
const { items, push, dismiss } = useToastStack(message)
const m = toastMotion(reduce)
const Icon = toneIcon[tone]
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-tone={tone}
className={cn(toastBase, toastWidth[size])}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition}
layout={!reduce}
>
{overlay}
{leading ?? <Icon className={cn("relative mt-0.5", toneIconColor[tone])} />}
<div className="relative min-w-0 flex-1 pt-0.5">{t.message}</div>
<button
type="button"
aria-label="Dismiss"
className={cn(closeBtn, "relative")}
onClick={() => dismiss(t.id)}
>
<X />
</button>
{indicator}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)
}
/* ------------------------------------------------------------------ BarToaster A progress bar shrinking beneath the toast. */
export function BarToaster({
className,
size = "md",
tone = "info",
label = "Show toast",
}: ProgressToasterProps) {
const reduce = useReducedMotion()
return (
<ProgressShell
message="Saving your draft..."
className={className}
size={size}
tone={tone}
label={label}
indicator={
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-1 bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
>
{reduce ? (
<div className={cn("h-full w-full", toneFill[tone])} />
) : (
<motion.div
className={cn("h-full", toneFill[tone])}
initial={{ width: "100%" }}
animate={{ width: "0%" }}
transition={{ duration: TOAST_SEC, ease: "linear" }}
/>
)}
</div>
}
/>
)
}
/* ----------------------------------------------------------------- RingToaster
Ikon yerinde bosalan dairesel halka. */
export function RingToaster({
className,
size = "md",
tone = "info",
label = "Show toast",
}: ProgressToasterProps) {
const reduce = useReducedMotion()
return (
<ProgressShell
message="Syncing your workspace..."
className={className}
size={size}
tone={tone}
label={label}
leading={
<span
aria-hidden
className={cn("relative mt-0.5 inline-flex shrink-0", toneIconColor[tone])}
>
<svg viewBox="0 0 20 20" fill="none" className="size-4 -rotate-90">
<circle
cx="10"
cy="10"
r="8"
stroke="currentColor"
strokeWidth="2.5"
strokeOpacity="0.2"
/>
{reduce ? (
<circle
cx="10"
cy="10"
r="8"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
) : (
<motion.circle
cx="10"
cy="10"
r="8"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
initial={{ pathLength: 1 }}
animate={{ pathLength: 0 }}
transition={{ duration: TOAST_SEC, ease: "linear" }}
/>
)}
</svg>
</span>
}
/>
)
}
/* A small badge counting the remaining seconds. It mounts with every toast and the interval is cleared on unmount. Under reduced-motion a static starting value is shown. */
function Countdown({ tone, reduce }: { tone: StyledTone; reduce: boolean | null }) {
const [left, setLeft] = React.useState(Math.round(TOAST_SEC))
React.useEffect(() => {
if (reduce) return
const interval = setInterval(() => {
setLeft((prev) => (prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(interval)
}, [reduce])
return (
<span
aria-hidden
className={cn(
"relative mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-current text-[10px] font-medium leading-none tabular-nums",
toneIconColor[tone]
)}
>
{left}
</span>
)
}
/* ------------------------------------------------------------ CountdownToaster
Kalan saniyeyi rakamla sayan rozet. */
export function CountdownToaster({
className,
size = "md",
tone = "info",
label = "Show toast",
}: ProgressToasterProps) {
const reduce = useReducedMotion()
return (
<ProgressShell
message="Closing this notice shortly."
className={className}
size={size}
tone={tone}
label={label}
leading={<Countdown tone={tone} reduce={reduce} />}
/>
)
}
/* ---------------------------------------------------------------- SweepToaster
Toast yuzeyini kaplayan ton katmani soldan saga cekilir. */
export function SweepToaster({
className,
size = "md",
tone = "info",
label = "Show toast",
}: ProgressToasterProps) {
const reduce = useReducedMotion()
return (
<ProgressShell
message="Queued for delivery."
className={className}
size={size}
tone={tone}
label={label}
overlay={
reduce ? (
<div aria-hidden className={cn("absolute inset-0", toneSweep[tone])} />
) : (
<motion.div
aria-hidden
className={cn("absolute inset-0 origin-left", toneSweep[tone])}
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: TOAST_SEC, ease: "linear" }}
/>
)
}
/>
)
}
/* ----------------------------------------------------------------- EdgeToaster A vertical line on the left edge draining from top to bottom. */
export function EdgeToaster({
className,
size = "md",
tone = "info",
label = "Show toast",
}: ProgressToasterProps) {
const reduce = useReducedMotion()
return (
<ProgressShell
message="Report is being generated."
className={className}
size={size}
tone={tone}
label={label}
indicator={
<div
aria-hidden
className="absolute inset-y-0 left-0 w-1 bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)]"
>
{reduce ? (
<div className={cn("h-full w-full", toneFill[tone])} />
) : (
<motion.div
className={cn("h-full w-full origin-top", toneFill[tone])}
initial={{ scaleY: 1 }}
animate={{ scaleY: 0 }}
transition={{ duration: TOAST_SEC, ease: "linear" }}
/>
)}
</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.
Bar
A progress bar along the bottom edge that drains to zero.
import { BarToaster } from "@/components/ui/sonner-progress"
<BarToaster />Ring
A circular ring in the icon slot that empties as time runs out.
import { RingToaster } from "@/components/ui/sonner-progress"
<RingToaster />Countdown
A numeric badge counting the remaining seconds.
import { CountdownToaster } from "@/components/ui/sonner-progress"
<CountdownToaster />Sweep
A tone-tinted layer that sweeps off the surface.
import { SweepToaster } from "@/components/ui/sonner-progress"
<SweepToaster />Edge
A vertical rule on the left edge that drains top to bottom.
import { EdgeToaster } from "@/components/ui/sonner-progress"
<EdgeToaster />ai2 Progress toasters: 5 styled variations on the token system
The ai2 Progress toasters are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around toast triggers with an auto-dismiss progress indicator. 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: framer-motion drains the bar, ring, sweep or edge across the fixed timeout. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the indicator is drawn static and the toast still dismisses on time.
What is in the ai2 Progress toasters?
5 exports in one file: Bar, Ring, Countdown, Sweep and Edge. 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: framer-motion drains the bar, ring, sweep or edge across the fixed timeout.
- Reduced-motion aware: Under prefers-reduced-motion, the indicator is drawn static and the toast still dismisses on time.
- 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 Progress toasters 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.