Snackbar

Brief, transient messages that appear at the bottom of the screen to provide feedback about an operation. Automatically dismisses after a short duration and supports optional actions.

Variants

Different visual styles for different types of feedback. Color helps convey meaning, but always include clear text messages.

i
Default

New message received

You have 1 unread notification

check
Success

Changes saved

Your profile has been updated

!
Error

Upload failed

File exceeds 10 MB limit

!
Warning

Session expiring

You'll be logged out in 5 minutes

Demo

Snackbars appear temporarily to acknowledge an action. They can include an optional action button.

Changes saved

Your profile has been updated

Anatomy

Changes saved successfullyYour profile has been updatedUndo×ICONACTIONDISMISS

Behavior

AspectBehaviorNotes
Auto-dismissAfter duration (default 4s)Set duration=0 to disable
HoverPauses timerResumes on mouse leave
FocusPauses timerAccessibility friendly
MultipleStack verticallyMost recent on top
Action clickDismisses afterThen executes callback
DismissImmediate closeNo further animation

Usage guidelines

✓ Do
  • ·Use for non-critical, transient feedback
  • ·Keep messages concise (1-2 lines max)
  • ·Provide action for undoable operations
  • ·Pause timer on hover/focus for accessibility
✗ Don't
  • ·Don't use for critical errors (use banner/modal)
  • ·Avoid stacking more than 2-3 at once
  • ·Don't require reading for critical info
  • ·Avoid long messages that wrap

Props

PropTypeDefaultDescription
open*booleanControls snackbar visibility.
onClose*() => voidCalled when snackbar closes.
message*stringMain message text to display.
descriptionstringOptional secondary text below the message.
variant"default" | "success" | "error" | "warning""default"Visual style variant.
durationnumber4000Auto-dismiss delay in milliseconds. 0 = never.
action{ label: string; onClick: () => void }Optional action button on the right.
position"bottom-left" | "bottom-center" | "bottom-right""bottom-right"Screen position.

Additional props

PropTypeDefaultDescription
dismissiblebooleanfalseShow a dismiss (×) button.
iconReactNodeOptional icon before message.

Implementation

Snackbar.tsx
tsx
"use client";

import { useEffect } from "react";
import { createPortal } from "react-dom";
import { CheckCircle, AlertCircle, AlertTriangle, X } from "lucide-react";
import { cn } from "@/lib";

type SnackbarVariant = "default" | "success" | "error" | "warning";
type SnackbarPosition = "bottom-left" | "bottom-center" | "bottom-right";

export interface SnackbarProps {
  open: boolean;
  onClose: () => void;
  message: string;
  description?: string;
  variant?: SnackbarVariant;
  duration?: number;
  action?: {
    label: string;
    onClick: () => void;
  };
  position?: SnackbarPosition;
  dismissible?: boolean;
  icon?: React.ReactNode;
}

const ICONS: Record<SnackbarVariant, React.ReactNode> = {
  default: null,
  success: <CheckCircle className="w-5 h-5" />,
  error: <AlertCircle className="w-5 h-5" />,
  warning: <AlertTriangle className="w-5 h-5" />,
};

const COLORS: Record<SnackbarVariant, string> = {
  default: "rgb(var(--text-primary))",
  success: "var(--nav-active-color)",
  error: "#ef4444",
  warning: "#f59e0b",
};

const POSITIONS: Record<SnackbarPosition, string> = {
  "bottom-left": "bottom-4 left-4",
  "bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
  "bottom-right": "bottom-4 right-4",
};

export function Snackbar({
  open,
  onClose,
  message,
  description,
  variant = "default",
  duration = 4000,
  action,
  position = "bottom-right",
  dismissible = false,
  icon,
}: SnackbarProps) {
  useEffect(() => {
    if (!open || duration === 0) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);

  if (!open) return null;

  const customIcon = icon ?? ICONS[variant];

  return createPortal(
    <div
      className={cn(
        "fixed z-[70] px-4",
        POSITIONS[position]
      )}
      role="status"
      aria-live="polite"
      aria-atomic="true"
    >
      <div
        className={cn(
          "flex items-start gap-3 px-4 py-3 rounded-lg shadow-[0_4px_16px_rgba(0,0,0,0.15)]",
          "border backdrop-blur-sm",
          "bg-[rgb(var(--surface-raised))] border-[rgb(var(--border))]"
        )}
        style={{
          borderLeftColor: customIcon ? COLORS[variant] : undefined,
          borderLeftWidth: customIcon ? "4px" : undefined,
        }}
      >
        {customIcon && (
          <div className="flex-shrink-0 mt-0.5" style={{ color: COLORS[variant] }}>
            {customIcon}
          </div>
        )}
        <div className="flex-1 min-w-0 pr-2">
          <p className="text-[13px] font-medium text-[rgb(var(--text-primary))] leading-snug">
            {message}
          </p>
          {description && (
            <p className="text-[12px] text-[rgb(var(--text-secondary))] mt-0.5 leading-snug">
              {description}
            </p>
          )}
        </div>
        {action && (
          <button
            onClick={() => {
              action.onClick();
              onClose();
            }}
            className="text-[12px] font-medium text-[rgb(var(--accent))] hover:underline whitespace-nowrap ml-2"
          >
            {action.label}
          </button>
        )}
        {dismissible && (
          <button
            onClick={onClose}
            className="flex-shrink-0 w-5 h-5 flex items-center justify-center rounded text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--text-secondary))] transition-colors"
            aria-label="Dismiss"
          >
            <X className="w-3 h-3" />
          </button>
        )}
      </div>
    </div>,
    document.body
  );
}

Accessibility

  • Use role='status' with aria-live='polite' for informational messages.
  • Use role='alert' only for critical errors requiring immediate attention.
  • Provide aria-label on dismiss button for screen readers.
  • Don't rely on color alone — include text and icons.
  • Timer should pause on hover and focus for keyboard users.
  • Avoid snackbars for critical errors or required confirmations.