Bottom Sheet

A mobile-style bottom sheet that slides up from the bottom edge over a backdrop. A drag-handle bar sits at the top, followed by a title, scrollable content, and action buttons. Backdrop click, the close button, or Esc dismisses it.

Modals & DialogsInspired by Stripe
Live PreviewInteractive
Stripe · Checkout

Backdrop click or × dismisses · Slides up from the pane bottom · Reset reopens

BottomSheet.tsx
// Dependencies: react ^18, lucide-react
import React, { useEffect, useState } from 'react';
import { X, CreditCard, Check } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

// ——— BottomSheet shell ———

type BottomSheetProps = {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  actions?: React.ReactNode;
};

export function BottomSheet({ open, onClose, title, children, actions }: BottomSheetProps) {
  useEffect(() => {
    if (!open) return;
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [open, onClose]);

  return (
    <div
      className={cn(
        'fixed inset-0 z-50 transition-opacity duration-200',
        open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
      )}
      role="dialog"
      aria-modal="true"
      aria-hidden={!open}
    >
      {/* Backdrop */}
      <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />

      {/* Sheet */}
      <div
        className={cn(
          'absolute bottom-0 left-0 right-0 max-h-[80%] bg-zinc-900 border-t border-white/10 rounded-t-2xl shadow-2xl flex flex-col transition-transform duration-300 ease-out',
          open ? 'translate-y-0' : 'translate-y-full'
        )}
      >
        {/* Drag handle */}
        <div className="flex justify-center pt-2.5 pb-1 shrink-0">
          <div className="w-9 h-1 rounded-full bg-zinc-700" />
        </div>

        {/* Header */}
        <div className="flex items-center justify-between px-5 pt-1 pb-3 border-b border-white/[0.08] shrink-0">
          <h2 className="text-sm font-semibold text-white">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close"
            className="text-zinc-500 hover:text-zinc-200 transition-colors"
          >
            <X className="w-4 h-4" />
          </button>
        </div>

        {/* Body */}
        <div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>

        {/* Actions — items-stretch guarantees equal button heights */}
        {actions && (
          <div className="px-5 py-3 border-t border-white/[0.08] flex items-stretch gap-3 shrink-0">
            {actions}
          </div>
        )}
      </div>
    </div>
  );
}

// ——— Usage example: Payment method sheet ———

const METHODS = [
  { id: 'visa',    label: 'Visa',       last4: '4242', expires: '04/27', bg: '#0f2d5f', color: '#60a5fa' },
  { id: 'mc',      label: 'Mastercard', last4: '5131', expires: '09/26', bg: '#5c1a08', color: '#f97316' },
  { id: 'paypal',  label: 'PayPal',     last4: null,   expires: null,    bg: '#0a2240', color: '#38bdf8' },
];

export function PaymentSheet() {
  const [open, setOpen] = useState(true);
  const [selected, setSelected] = useState('visa');

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        className="px-4 py-2 rounded-md text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-400 transition-colors"
      >
        Choose payment method
      </button>

      <BottomSheet
        open={open}
        onClose={() => setOpen(false)}
        title="Payment method"
        actions={
          <>
            <button
              onClick={() => setOpen(false)}
              className="px-4 py-2.5 rounded-md text-sm font-medium text-zinc-300 border border-white/[0.1] hover:bg-white/[0.05] transition-colors"
            >
              Cancel
            </button>
            <button
              onClick={() => setOpen(false)}
              className="flex-1 px-4 py-2.5 rounded-md text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-400 transition-colors"
            >
              Confirm
            </button>
          </>
        }
      >
        <div className="flex flex-col gap-2">
          {METHODS.map((m) => (
            <button
              key={m.id}
              onClick={() => setSelected(m.id)}
              className={cn(
                'flex items-center gap-3 px-3.5 py-3 rounded-lg border transition-colors text-left w-full',
                m.id === selected
                  ? 'border-indigo-500/40 bg-indigo-500/10'
                  : 'border-white/[0.08] hover:bg-white/[0.04]'
              )}
            >
              <div
                className="w-8 h-8 rounded-md flex items-center justify-center shrink-0"
                style={{ backgroundColor: m.bg }}
              >
                <CreditCard className="w-4 h-4" style={{ color: m.color }} />
              </div>
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium text-white">
                  {m.label}{m.last4 ? ` ···· ${m.last4}` : ''}
                </p>
                {m.expires && (
                  <p className="text-xs text-zinc-500">Expires {m.expires}</p>
                )}
              </div>
              {m.id === selected && (
                <Check className="w-4 h-4 text-indigo-400 shrink-0" />
              )}
            </button>
          ))}
        </div>
      </BottomSheet>
    </>
  );
}