Toast (Sonner)

Async feedback after an action.

Motion: Sonner stack animation; Solar status icons. Glass; success / warning / error haptics.

Installation

npx shadcn@latest add @craft/toast

Source

components/ui/sonner.tsx
"use client"

import * as React from "react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, useSonner, type ToasterProps } from "sonner"
import { CheckCircleIcon, CloseIcon, DangerTriangleIcon, InfoCircleIcon, CloseCircleIcon } from "@solar-icons/react/linear"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { haptic, useHaptics } from "@/lib/haptics"

/** Icon disc tinted by toast type (sonner sets data-type on the toast) */
const iconTone = cn(
  "relative grid size-8 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground [&_svg]:size-[18px]",
  "group-data-[type=success]/toast:bg-success/10 group-data-[type=success]/toast:text-success",
  "group-data-[type=error]/toast:bg-destructive/10 group-data-[type=error]/toast:text-destructive",
  "group-data-[type=warning]/toast:bg-warning/15 group-data-[type=warning]/toast:text-warning-foreground",
  "group-data-[type=info]/toast:bg-primary/10 group-data-[type=info]/toast:text-primary"
)

/**
 * Sonner, fully styled with Craft tokens (`unstyled` drops sonner's own look but keeps
 * its stacking, swipe and enter/exit motion). Layout: tinted icon disc · title + description ·
 * optional action; ✕ appears on hover. Usage stays `toast.success("Changes saved", { description })`.
 */
/** Success / warning / error toasts buzz once on phones when they appear */
function ToastHaptics() {
  const { toasts } = useSonner()
  const { trigger } = useHaptics()
  const seen = React.useRef(new Set<string | number>())
  React.useEffect(() => {
    for (const t of toasts) {
      if (seen.current.has(t.id)) continue
      seen.current.add(t.id)
      if (t.type === "success" || t.type === "warning" || t.type === "error") trigger(haptic[t.type])
    }
  }, [toasts, trigger])
  return null
}

const Toaster = ({ ...props }: ToasterProps) => {
  const { theme = "system" } = useTheme()

  return (
    <>
      <ToastHaptics />
      <Sonner
        theme={theme as ToasterProps["theme"]}
        className="toaster group"
        gap={10}
        closeButton
        icons={{
          success: <CheckCircleIcon />,
          info: <InfoCircleIcon />,
          warning: <DangerTriangleIcon />,
          error: <CloseCircleIcon />,
          loading: <Spinner />,
          close: <CloseIcon className="size-3" />,
        }}
        toastOptions={{
          unstyled: true,
          classNames: {
            toast: cn(
              "group/toast pointer-events-auto flex w-(--width) items-start gap-3 rounded-2xl border glass p-3 pr-3.5",
              "text-popover-foreground"
            ),
            icon: iconTone,
            content: "grid min-w-0 flex-1 gap-0.5 pt-[5px]",
            title: "text-sm leading-snug font-medium",
            description: "text-[13px] leading-snug text-muted-foreground",
            actionButton: cn(buttonVariants({ size: "sm" }), "shrink-0 self-center"),
            cancelButton: cn(buttonVariants({ variant: "ghost", size: "sm" }), "shrink-0 self-center"),
            closeButton: cn(
              "absolute -top-2 -left-2 grid size-5 place-items-center rounded-full border bg-popover text-muted-foreground",
              "opacity-0 transition-opacity duration-(--motion-fast) group-hover/toast:opacity-100 focus-visible:opacity-100 hover:text-foreground"
            ),
          },
        }}
        {...props}
      />
    </>
  )
}

export { Toaster }