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*boolean—Controls snackbar visibility.
onClose*() => void—Called when snackbar closes.
message*string—Main message text to display.
descriptionstring—Optional 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.
iconReactNode—Optional 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.