Sonner

An opinionated toast component for React.

The Sonner component provides a toast notification system for displaying temporary messages. It supports multiple variants (success, info, warning, error), actions, promises, and customizable positioning. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/sonner.tsx:

tsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
import { Button } from "./button"

type ToastVariant = "default" | "success" | "info" | "warning" | "error"

interface Toast {
  id: string
  title: string
  description?: string
  variant?: ToastVariant
  action?: {
    label: string
    onClick: () => void
  }
  cancel?: {
    label: string
    onClick: () => void
  }
  duration?: number
  promise?: Promise<any>
}

interface ToastContextValue {
  toasts: Toast[]
  toast: (options: Omit<Toast, "id">) => string
  dismiss: (id: string) => void
}

const ToastContext = React.createContext<ToastContextValue | undefined>(undefined)

const useToast = () => {
  const context = React.useContext(ToastContext)
  if (!context) {
    throw new Error("useToast must be used within Toaster")
  }
  return context
}

interface ToasterProps {
  position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"
  children?: React.ReactNode
}

const Toaster = ({ position = "bottom-right", children }: ToasterProps) => {
  const [toasts, setToasts] = React.useState<Toast[]>([])

  const toast = React.useCallback((options: Omit<Toast, "id">) => {
    const id = Math.random().toString(36).substring(2, 9)
    const newToast: Toast = {
      id,
      duration: 5000,
      ...options,
    }

    setToasts((prev) => [...prev, newToast])

    if (newToast.duration && newToast.duration > 0) {
      setTimeout(() => {
        dismiss(id)
      }, newToast.duration)
    }

    if (newToast.promise) {
      newToast.promise
        .then(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, variant: "success", title: "Success", description: "Operation completed successfully" }
                : t
            )
          )
          setTimeout(() => dismiss(id), 3000)
        })
        .catch(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, variant: "error", title: "Error", description: "Operation failed" }
                : t
            )
          )
          setTimeout(() => dismiss(id), 3000)
        })
    }

    return id
  }, [])

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

  const getPositionClasses = () => {
    switch (position) {
      case "top-left":
        return "top-4 left-4"
      case "top-right":
        return "top-4 right-4"
      case "bottom-left":
        return "bottom-4 left-4"
      case "bottom-right":
      default:
        return "bottom-4 right-4"
    }
  }

  return (
    <ToastContext.Provider value={{ toasts, toast, dismiss }}>
      {children}
      {ReactDOM.createPortal(
        <div
          className={cn(
            "fixed z-[100] flex flex-col gap-2 w-full max-w-[420px] p-4",
            getPositionClasses()
          )}
        >
          {toasts.map((toast) => (
            <ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
          ))}
        </div>,
        document.body
      )}
    </ToastContext.Provider>
  )
}

interface ToastItemProps {
  toast: Toast
  onDismiss: (id: string) => void
}

const ToastItem = ({ toast, onDismiss }: ToastItemProps) => {
  const getVariantClasses = () => {
    switch (toast.variant) {
      case "success":
        return "bg-green-500 text-white border-green-700"
      case "info":
        return "bg-blue-500 text-white border-blue-700"
      case "warning":
        return "bg-yellow-500 text-white border-yellow-700"
      case "error":
        return "bg-red-500 text-white border-red-700"
      default:
        return "bg-background text-foreground border-foreground"
    }
  }

  return (
    <div
      className={cn(
        "flex items-start gap-3 rounded-lg border-2 p-4 neobrutalism-shadow",
        getVariantClasses()
      )}
    >
      <div className="flex-1 min-w-0">
        <div className="font-bold text-sm">{toast.title}</div>
        {toast.description && (
          <div className="text-xs mt-1 opacity-90">{toast.description}</div>
        )}
      </div>
      <div className="flex items-center gap-2 flex-shrink-0">
        {toast.action && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => {
              toast.action?.onClick()
              onDismiss(toast.id)
            }}
            className="h-7 px-2 text-xs"
          >
            {toast.action.label}
          </Button>
        )}
        {toast.cancel && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => {
              toast.cancel?.onClick()
              onDismiss(toast.id)
            }}
            className="h-7 px-2 text-xs"
          >
            {toast.cancel.label}
          </Button>
        )}
        <button
          onClick={() => onDismiss(toast.id)}
          className="ml-1 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
        >
          <svg
            className="h-4 w-4"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
      </div>
    </div>
  )
}

export { Toaster, useToast }

JavaScript: Copy this code into components/ui/sonner.jsx:

jsx
"use client"

import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
import { Button } from "./button"

const ToastContext = React.createContext(undefined)

const useToast = () => {
  const context = React.useContext(ToastContext)
  if (!context) {
    throw new Error("useToast must be used within Toaster")
  }
  return context
}

const Toaster = ({ position = "bottom-right", children }) => {
  const [toasts, setToasts] = React.useState([])

  const toast = React.useCallback((options) => {
    const id = Math.random().toString(36).substring(2, 9)
    const newToast = {
      id,
      duration: 5000,
      ...options,
    }

    setToasts((prev) => [...prev, newToast])

    if (newToast.duration && newToast.duration > 0) {
      setTimeout(() => {
        dismiss(id)
      }, newToast.duration)
    }

    if (newToast.promise) {
      newToast.promise
        .then(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, variant: "success", title: "Success", description: "Operation completed successfully" }
                : t
            )
          )
          setTimeout(() => dismiss(id), 3000)
        })
        .catch(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, variant: "error", title: "Error", description: "Operation failed" }
                : t
            )
          )
          setTimeout(() => dismiss(id), 3000)
        })
    }

    return id
  }, [])

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

  const getPositionClasses = () => {
    switch (position) {
      case "top-left":
        return "top-4 left-4"
      case "top-right":
        return "top-4 right-4"
      case "bottom-left":
        return "bottom-4 left-4"
      case "bottom-right":
      default:
        return "bottom-4 right-4"
    }
  }

  return (
    <ToastContext.Provider value={{ toasts, toast, dismiss }}>
      {children}
      {ReactDOM.createPortal(
        <div
          className={cn(
            "fixed z-[100] flex flex-col gap-2 w-full max-w-[420px] p-4",
            getPositionClasses()
          )}
        >
          {toasts.map((toast) => (
            <ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
          ))}
        </div>,
        document.body
      )}
    </ToastContext.Provider>
  )
}

const ToastItem = ({ toast, onDismiss }) => {
  const getVariantClasses = () => {
    switch (toast.variant) {
      case "success":
        return "bg-green-500 text-white border-green-700"
      case "info":
        return "bg-blue-500 text-white border-blue-700"
      case "warning":
        return "bg-yellow-500 text-white border-yellow-700"
      case "error":
        return "bg-red-500 text-white border-red-700"
      default:
        return "bg-background text-foreground border-foreground"
    }
  }

  return (
    <div
      className={cn(
        "flex items-start gap-3 rounded-lg border-2 p-4 neobrutalism-shadow",
        getVariantClasses()
      )}
    >
      <div className="flex-1 min-w-0">
        <div className="font-bold text-sm">{toast.title}</div>
        {toast.description && (
          <div className="text-xs mt-1 opacity-90">{toast.description}</div>
        )}
      </div>
      <div className="flex items-center gap-2 flex-shrink-0">
        {toast.action && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => {
              toast.action?.onClick()
              onDismiss(toast.id)
            }}
            className="h-7 px-2 text-xs"
          >
            {toast.action.label}
          </Button>
        )}
        {toast.cancel && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => {
              toast.cancel?.onClick()
              onDismiss(toast.id)
            }}
            className="h-7 px-2 text-xs"
          >
            {toast.cancel.label}
          </Button>
        )}
        <button
          onClick={() => onDismiss(toast.id)}
          className="ml-1 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
        >
          <svg
            className="h-4 w-4"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
      </div>
    </div>
  )
}

export { Toaster, useToast }

Usage

TypeScript:

tsx
import { Toaster, useToast } from "@/components/ui/sonner"

function MyComponent() {
  return (
    <Toaster>
      <ToastButton />
    </Toaster>
  )
}

function ToastButton() {
  const { toast } = useToast()

  return (
    <button
      onClick={() => {
        toast({
          title: "Event has been created",
          description: "Sunday, December 03, 2023 at 9:00 AM",
        })
      }}
    >
      Show Toast
    </button>
  )
}

JavaScript:

jsx
import { Toaster, useToast } from "@/components/ui/sonner"

function MyComponent() {
  return (
    <Toaster>
      <ToastButton />
    </Toaster>
  )
}

function ToastButton() {
  const { toast } = useToast()

  return (
    <button
      onClick={() => {
        toast({
          title: "Event has been created",
          description: "Sunday, December 03, 2023 at 9:00 AM",
        })
      }}
    >
      Show Toast
    </button>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function.

Examples

Default

Success

Info

Warning

Error

Action

Cancel

Promise