Feedback

Toast

Transient, non-blocking feedback messages, styled with the quebi design system. Self-contained queue with a portalled, aria-live stack and five status intents.

toastnotificationfeedbackalertsnackbar

Autosave

Default toast for autosave confirmations. Keep copy specific — include the timestamp.

Status intents

Success / warning / error / info map to the quebi status ramps.

With description

A bold title with a muted secondary line below.

Persistent

Pass duration={0} to keep a toast until it is dismissed.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react"
import { createPortal } from "react-dom"
import { tv } from "tailwind-variants"
import { cn } from "@/lib/utils"

/**
 * Toast — quebi design system
 *
 * Transient, non-blocking feedback. Self-contained (no external toast lib):
 * a context-backed queue renders a fixed, portalled stack with an aria-live
 * region for accessibility.
 *
 * Intents: default (neutral surface), success (emerald), warning (amber),
 * danger (red), info (cyan) — matching the quebi status ramps. Auto-dismiss
 * after `duration` ms (set to 0 to keep until dismissed).
 *
 * Usage:
 *   <ToastProvider> wraps your app, then call `const toast = useToast()`
 *   and fire `toast.success("Saved")` / `toast({ title, description })`.
 */

type IconProps = React.SVGProps<SVGSVGElement>

const baseIconProps: IconProps = {
  viewBox: "0 0 24 24",
  fill: "currentColor",
  "aria-hidden": "true",
}

const CheckCircleIcon = (props: IconProps) => (
  <svg {...baseIconProps} {...props}>
    <path
      fillRule="evenodd"
      d="M2.25 12a9.75 9.75 0 1 1 19.5 0 9.75 9.75 0 0 1-19.5 0Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
      clipRule="evenodd"
    />
  </svg>
)

const ExclamationTriangleIcon = (props: IconProps) => (
  <svg {...baseIconProps} {...props}>
    <path
      fillRule="evenodd"
      d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
      clipRule="evenodd"
    />
  </svg>
)

const XCircleIcon = (props: IconProps) => (
  <svg {...baseIconProps} {...props}>
    <path
      fillRule="evenodd"
      d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"
      clipRule="evenodd"
    />
  </svg>
)

const InformationCircleIcon = (props: IconProps) => (
  <svg {...baseIconProps} {...props}>
    <path
      fillRule="evenodd"
      d="M2.25 12a9.75 9.75 0 1 1 19.5 0 9.75 9.75 0 0 1-19.5 0Zm9-1.5a.75.75 0 0 0 0 1.5h.255a.75.75 0 0 1 .73.926l-.708 2.836A1.75 1.75 0 0 0 13.225 18h.526a.75.75 0 0 0 0-1.5h-.255a.25.25 0 0 1-.243-.31l.71-2.836A1.75 1.75 0 0 0 12.265 11H11.25Zm.75-3.75a1.125 1.125 0 1 0 0 2.25 1.125 1.125 0 0 0 0-2.25Z"
      clipRule="evenodd"
    />
  </svg>
)

const CloseIcon = (props: IconProps) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" {...props}>
    <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
  </svg>
)

export type ToastIntent = "default" | "success" | "warning" | "danger" | "info"

export interface ToastOptions {
  /** Headline text or node. */
  title: React.ReactNode
  /** Optional secondary line below the title. */
  description?: React.ReactNode
  /** Status colour ramp. */
  intent?: ToastIntent
  /** Auto-dismiss after N ms. 0 keeps the toast until dismissed. Default 4000. */
  duration?: number
}

interface ToastRecord extends Required<Omit<ToastOptions, "description">> {
  id: string
  description?: React.ReactNode
}

export type ToastPosition =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right"

interface ToastApi {
  (options: ToastOptions): string
  success: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
  warning: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
  error: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
  info: (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "intent">) => string
  dismiss: (id: string) => void
}

const ToastContext = createContext<ToastApi | null>(null)

/** Imperative access to the toast queue. Must be used under a ToastProvider. */
export function useToast(): ToastApi {
  const ctx = useContext(ToastContext)
  if (!ctx) throw new Error("useToast must be used within a <ToastProvider>")
  return ctx
}

const iconMap = {
  default: null,
  success: CheckCircleIcon,
  warning: ExclamationTriangleIcon,
  danger: XCircleIcon,
  info: InformationCircleIcon,
} as const

const toastStyles = tv({
  base: [
    "pointer-events-auto flex w-full max-w-sm items-start gap-3",
    "rounded-quebi-md border p-4 text-sm/5 text-pretty backdrop-blur-sm",
    "shadow-quebi-glow",
    "transition-all duration-200 ease-out",
  ],
  variants: {
    intent: {
      default: "border-cyan-500/10 bg-quebi-bg/90 text-quebi-fg-muted",
      success: "border-emerald-500/20 bg-emerald-500/10 text-emerald-200",
      warning: "border-amber-500/20 bg-amber-500/10 text-amber-200",
      danger: "border-red-500/20 bg-red-500/10 text-red-200",
      info: "border-cyan-500/20 bg-cyan-500/10 text-cyan-200",
    },
  },
  defaultVariants: { intent: "default" },
})

const positionStyles: Record<ToastPosition, string> = {
  "top-left": "top-0 left-0 items-start",
  "top-center": "top-0 left-1/2 -translate-x-1/2 items-center",
  "top-right": "top-0 right-0 items-end",
  "bottom-left": "bottom-0 left-0 items-start",
  "bottom-center": "bottom-0 left-1/2 -translate-x-1/2 items-center",
  "bottom-right": "bottom-0 right-0 items-end",
}

function ToastItem({ toast, onDismiss }: { toast: ToastRecord; onDismiss: (id: string) => void }) {
  const Icon = iconMap[toast.intent]

  useEffect(() => {
    if (!toast.duration) return
    const timer = setTimeout(() => onDismiss(toast.id), toast.duration)
    return () => clearTimeout(timer)
  }, [toast.id, toast.duration, onDismiss])

  return (
    <div data-slot="toast" className={cn(toastStyles({ intent: toast.intent }))}>
      {Icon && <Icon className="mt-px size-5 shrink-0" />}
      <div className="min-w-0 flex-1">
        <div className="font-semibold text-white">{toast.title}</div>
        {toast.description && (
          <div className="mt-1 text-quebi-fg-muted">{toast.description}</div>
        )}
      </div>
      <button
        type="button"
        aria-label="Dismiss notification"
        onClick={() => onDismiss(toast.id)}
        className={cn(
          "-mr-1 -mt-1 shrink-0 cursor-pointer rounded-quebi-sm p-1 text-current/70",
          "transition-colors duration-150 hover:bg-white/10 hover:text-current",
          "outline-none focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
        )}
      >
        <CloseIcon className="size-4" />
      </button>
    </div>
  )
}

export interface ToastProviderProps {
  children: React.ReactNode
  /** Where the stack docks on screen. Default "bottom-right". */
  position?: ToastPosition
  /** Default auto-dismiss for toasts that don't set one. Default 4000ms. */
  duration?: number
}

let counter = 0

export function ToastProvider({
  children,
  position = "bottom-right",
  duration = 4000,
}: ToastProviderProps) {
  const [toasts, setToasts] = useState<ToastRecord[]>([])
  const mounted = useRef(false)

  useEffect(() => {
    mounted.current = true
    return () => {
      mounted.current = false
    }
  }, [])

  const dismiss = useCallback((id: string) => {
    setToasts((prev) => prev.filter((t) => t.id !== id))
  }, [])

  const api = useMemo<ToastApi>(() => {
    const push = (options: ToastOptions) => {
      const id = `toast-${++counter}`
      setToasts((prev) => [
        ...prev,
        {
          id,
          title: options.title,
          description: options.description,
          intent: options.intent ?? "default",
          duration: options.duration ?? duration,
        },
      ])
      return id
    }

    const fn = ((options: ToastOptions) => push(options)) as ToastApi
    fn.success = (title, opts) => push({ ...opts, title, intent: "success" })
    fn.warning = (title, opts) => push({ ...opts, title, intent: "warning" })
    fn.error = (title, opts) => push({ ...opts, title, intent: "danger" })
    fn.info = (title, opts) => push({ ...opts, title, intent: "info" })
    fn.dismiss = dismiss
    return fn
  }, [duration, dismiss])

  const isTop = position.startsWith("top")

  const viewport =
    typeof document !== "undefined"
      ? createPortal(
          <div
            role="region"
            aria-label="Notifications"
            className={cn(
              "pointer-events-none fixed z-50 flex w-full max-w-sm flex-col gap-3 p-4",
              isTop ? "flex-col" : "flex-col-reverse",
              positionStyles[position],
            )}
          >
            <div aria-live="polite" aria-atomic="false" className="sr-only">
              {toasts.map((t) => (
                <div key={t.id}>{t.title}</div>
              ))}
            </div>
            {toasts.map((t) => (
              <ToastItem key={t.id} toast={t} onDismiss={dismiss} />
            ))}
          </div>,
          document.body,
        )
      : null

  return (
    <ToastContext.Provider value={api}>
      {children}
      {viewport}
    </ToastContext.Provider>
  )
}