litenBeta

Toast

A layered toast that stacks into a pile, expands on hover, and counts down through a draining bed of colored pixels instead of a plain bar.

Toasts land from below as a bezel-and-panel card, pile up newest in front, and fan open when you hover the stack. Each type carries a lucide status mark in its own accent, the auto-dismiss timer drains as a two-row bed of colored squares, and toast.promise resolves a spinning pending chip in place into a receipt. Built with Motion.

Installation

Complete the shared Setup first, then copy the component into components/ui/toast.tsx.

Terminal
bun add motion lucide-react
components/ui/toast.tsx
'use client';import * as React from 'react';import { motion, AnimatePresence, useReducedMotion } from 'motion/react';import {  Check,  TriangleAlert,  Diamond,  Loader,  type LucideIcon,} from 'lucide-react';import { cn } from '@/lib/cn';const EASE = [0.23, 1, 0.32, 1] as const;export type ToastType = 'success' | 'error' | 'info' | 'pending';const LOOK: Record<  ToastType,  { tint: string; deep: string; accent: string }> = {  success: { tint: '#5BD79C', deep: '#22A56E', accent: '#48CE8D' },  error: { tint: '#F5897F', deep: '#DA463A', accent: '#F0736A' },  info: { tint: '#93B9FF', deep: '#5B84E6', accent: '#93B9FF' },  pending: { tint: '#8C8C84', deep: '#5C5C55', accent: '#A2A29A' },};const ICON: Record<ToastType, LucideIcon> = {  success: Check,  error: TriangleAlert,  info: Diamond,  pending: Loader,};const clamp = (n: number, lo: number, hi: number) =>  Math.min(hi, Math.max(lo, n));function ramp(a: string, b: string, t: number, k: number) {  const A = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));  const B = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));  const c = A.map((v, i) => clamp((v + (B[i] - v) * t) * k, 0, 255) | 0);  return `rgb(${c[0]},${c[1]},${c[2]})`;}function jitter(r: number, c: number) {  const h = ((r * 73856093) ^ (c * 19349663)) >>> 0;  return (h % 1000) / 1000;}const PX_COLS = 36;const PX_ROWS = 2;function PixelTimer({  type,  duration,  paused,  reduce,  onDone,}: {  type: ToastType;  duration: number;  paused: boolean;  reduce: boolean | null;  onDone: () => void;}) {  const l = LOOK[type];  const [spent, setSpent] = React.useState(0);  const pausedRef = React.useRef(paused);  pausedRef.current = paused;  const doneRef = React.useRef(onDone);  doneRef.current = onDone;  React.useEffect(() => {    if (reduce) return;    let raf = 0;    let prev = performance.now();    let elapsed = 0;    let stopped = false;    const loop = (now: number) => {      if (stopped) return;      const dt = now - prev;      prev = now;      if (!pausedRef.current) elapsed += dt;      const p = Math.min(1, elapsed / duration);      const s = Math.floor(p * PX_COLS);      setSpent((prevS) => (prevS === s ? prevS : s));      if (p >= 1) {        doneRef.current();        return;      }      raf = requestAnimationFrame(loop);    };    raf = requestAnimationFrame(loop);    return () => {      stopped = true;      cancelAnimationFrame(raf);    };  }, [duration, reduce]);  const cells: React.ReactNode[] = [];  for (let r = 0; r < PX_ROWS; r++) {    for (let c = 0; c < PX_COLS; c++) {      const on = c >= spent;      const t = c / (PX_COLS - 1);      const j = jitter(r, c);      cells.push(        <span          key={`${r}-${c}`}          className="h-[3.5px] rounded-[1px]"          style={            on              ? {                  background: ramp(l.deep, l.tint, t, 0.68 + 0.52 * j),                  opacity: 0.6 + 0.4 * j,                }              : {                  background: 'var(--tst-px-off)',                  opacity: 0.5 + 0.5 * j,                }          }        />,      );    }  }  return (    <div      aria-hidden      className="grid gap-[2px] px-3 pb-2.5 pt-0.5"      style={{        gridTemplateColumns: `repeat(${PX_COLS}, minmax(0, 1fr))`,        gridAutoRows: '3.5px',      }}    >      {cells}    </div>  );}export type ToastInput = {  title: string;  desc?: string;  type?: ToastType;  duration?: number;  action?: { label: string; icon?: LucideIcon; onClick?: () => void };};type ToastData = ToastInput & { id: number; type: ToastType };let list: ToastData[] = [];const subs = new Set<() => void>();let uid = 0;function emit() {  subs.forEach((f) => f());}function push(input: ToastInput): number {  const id = ++uid;  const type = input.type ?? 'info';  const duration =    input.duration ??    (type === 'pending' ? 0 : type === 'error' ? 5200 : 4200);  list = [{ ...input, id, type, duration }, ...list];  emit();  return id;}function dismiss(id: number) {  list = list.filter((t) => t.id !== id);  emit();}function update(id: number, patch: Partial<ToastInput>) {  list = list.map((t) => {    if (t.id !== id) return t;    const next = { ...t, ...patch } as ToastData;    if (patch.duration === undefined && patch.type && patch.type !== 'pending') {      next.duration = patch.type === 'error' ? 5200 : 4200;    }    return next;  });  emit();}export const toast = Object.assign(  (input: ToastInput) => push(input),  {    success: (title: string, o: Partial<ToastInput> = {}) =>      push({ ...o, title, type: 'success' }),    error: (title: string, o: Partial<ToastInput> = {}) =>      push({ ...o, title, type: 'error' }),    info: (title: string, o: Partial<ToastInput> = {}) =>      push({ ...o, title, type: 'info' }),    pending: (title: string, o: Partial<ToastInput> = {}) =>      push({ ...o, title, type: 'pending', duration: 0 }),    dismiss,    update,    promise<Tn>(      run: Promise<Tn>,      msg: {        loading: string;        success: string | ((v: Tn) => string);        error: string;        desc?: string;      },    ) {      const id = push({ title: msg.loading, type: 'pending', duration: 0 });      run        .then((v) =>          update(id, {            title:              typeof msg.success === 'function' ? msg.success(v) : msg.success,            desc: msg.desc,            type: 'success',          }),        )        .catch(() => update(id, { title: msg.error, type: 'error' }));      return id;    },  },);function StatusMark({ type }: { type: ToastType }) {  const reduce = useReducedMotion();  const l = LOOK[type];  const Icon = ICON[type];  return (    <span aria-hidden className="grid size-[30px] shrink-0 place-items-center">      <Icon        size={type === 'info' ? 17 : 19}        strokeWidth={type === 'info' ? 2.6 : 2.5}        color={l.accent}        className={type === 'pending' && !reduce ? 'tst-spin' : undefined}        {...(type === 'info' ? { fill: `${l.accent}33` } : {})}      />    </span>  );}type Layout = {  ty: number;  s: number;  o: number;  contentShown: boolean;  z: number;};function ToastCard({  t,  layout,  paused,  reduce,  onHeight,  onClose,}: {  t: ToastData;  layout: Layout;  paused: boolean;  reduce: boolean | null;  onHeight: (id: number, h: number) => void;  onClose: () => void;}) {  const measure = React.useRef<HTMLDivElement>(null);  React.useEffect(() => {    const el = measure.current;    if (!el) return;    const report = () => onHeight(t.id, el.offsetHeight);    report();    const ro = new ResizeObserver(report);    ro.observe(el);    return () => ro.disconnect();  }, [t.id, onHeight]);  React.useEffect(() => {    if (!reduce) return;    if (t.type === 'pending' || !t.duration) return;    const id = window.setTimeout(onClose, t.duration);    return () => window.clearTimeout(id);  }, [reduce, t.type, t.duration, onClose]);  const timed = t.type !== 'pending' && !!t.duration;  return (    <motion.div      className="absolute inset-x-0 top-0"      style={{ zIndex: layout.z, transformOrigin: '50% 0%' }}      initial={        reduce          ? { opacity: 0 }          : { opacity: 0, y: layout.ty - 26, scale: 0.9, filter: 'blur(8px)' }      }      animate={        reduce          ? { opacity: layout.o }          : {              opacity: layout.o,              y: layout.ty,              scale: layout.s,              filter: 'blur(0px)',            }      }      exit={        reduce          ? { opacity: 0 }          : { opacity: 0, y: layout.ty - 12, scale: 0.92, filter: 'blur(6px)' }      }      transition={{ duration: 0.34, ease: EASE }}    >      <div ref={measure}>        <div          className="rounded-[18px] p-[5px]"          style={{            background: 'var(--tst-bezel)',            boxShadow: 'var(--tst-bezel-shadow)',          }}        >          <div            className="relative overflow-hidden rounded-[14px]"            style={{              background: 'var(--tst-panel)',              boxShadow: 'var(--tst-panel-inset)',            }}          >            <div              className="flex items-stretch transition-opacity duration-200"              style={{                opacity: layout.contentShown ? 1 : 0,                pointerEvents: layout.contentShown ? 'auto' : 'none',              }}            >              <div className="flex min-w-0 flex-1 items-center gap-3 p-3">                <span className="relative">                  <AnimatePresence mode="wait" initial={false}>                    <motion.span                      key={t.type}                      initial={reduce ? false : { opacity: 0, scale: 0.5 }}                      animate={{ opacity: 1, scale: 1 }}                      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}                      transition={{ duration: 0.22, ease: EASE }}                      className="block"                    >                      <StatusMark type={t.type} />                    </motion.span>                  </AnimatePresence>                </span>                <div className="min-w-0 flex-1">                  <p                    className="truncate text-[13px] font-medium leading-tight"                    style={{ color: 'var(--tst-text)' }}                  >                    {t.title}                  </p>                  {t.desc && (                    <p                      className="mt-0.5 truncate text-[11px] leading-tight"                      style={{ color: 'var(--tst-mute)' }}                    >                      {t.desc}                    </p>                  )}                </div>                <button                  aria-label="Dismiss"                  onClick={onClose}                  className={cn(                    'mt-px grid size-6 shrink-0 place-items-center rounded-[7px]',                    'text-[13px] leading-none outline-none transition-[color,transform] duration-100',                    'hover:[color:var(--tst-mute)] active:scale-90 motion-reduce:active:scale-100',                  )}                  style={{ color: 'var(--tst-faint)' }}                >                </button>              </div>              {t.action && (                <button                  onClick={() => {                    t.action?.onClick?.();                    onClose();                  }}                  className={cn(                    'flex shrink-0 items-center gap-1.5 px-4 text-[11.5px] font-medium outline-none',                    'transition-[background-color,transform] duration-100',                    'hover:[background:var(--tst-well)] active:scale-[0.98] motion-reduce:active:scale-100',                  )}                  style={{                    color: 'var(--tst-text)',                    borderLeft: '1px solid var(--tst-line)',                    background: 'transparent',                  }}                >                  {t.action.icon && (                    <t.action.icon                      size={14}                      strokeWidth={2.3}                      color={LOOK[t.type].accent}                    />                  )}                  {t.action.label}                </button>              )}            </div>            {timed && (              <div                className="transition-opacity duration-200"                style={{ opacity: layout.contentShown ? 1 : 0 }}              >                <PixelTimer                  key={t.duration}                  type={t.type}                  duration={t.duration!}                  paused={paused}                  reduce={reduce}                  onDone={onClose}                />              </div>            )}          </div>        </div>      </div>    </motion.div>  );}const GAP = 12;const PEEK = 15;export type ToasterProps = {  max?: number;  className?: string;};export function Toaster({ max = 5, className }: ToasterProps) {  const data = React.useSyncExternalStore(    (cb) => {      subs.add(cb);      return () => subs.delete(cb);    },    () => list,    () => list,  );  const reduce = useReducedMotion();  const [expanded, setExpanded] = React.useState(false);  const [heights, setHeights] = React.useState<Record<number, number>>({});  const onHeight = React.useCallback((id: number, h: number) => {    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));  }, []);  const visible = data.slice(0, max);  React.useEffect(() => {    if (visible.length <= 1 && expanded) setExpanded(false);  }, [visible.length, expanded]);  const frontH = heights[visible[0]?.id] ?? 78;  let acc = 0;  const layouts: Layout[] = visible.map((t, i) => {    const h = heights[t.id] ?? 78;    const expandedY = acc;    acc += h + GAP;    const depth = Math.min(i, 2);    return {      ty: expanded ? expandedY : depth * PEEK,      s: expanded ? 1 : 1 - depth * 0.05,      o: expanded ? 1 : i < 3 ? 1 : 0,      contentShown: expanded || i === 0,      z: 100 - i,    };  });  const wrapH = expanded ? Math.max(acc - GAP, frontH) : frontH + 2 * PEEK;  return (    <div      className={cn(        'tst-root pointer-events-none fixed inset-0 z-[100] flex items-start justify-end p-4 sm:p-6',        className,      )}    >      <div        className="pointer-events-auto relative w-[380px] max-w-[calc(100vw-3rem)]"        style={{          height: visible.length ? wrapH : 0,          transition: 'height 0.34s cubic-bezier(0.23,1,0.32,1)',        }}        onMouseEnter={() => visible.length > 1 && setExpanded(true)}        onMouseLeave={() => setExpanded(false)}      >        <AnimatePresence>          {visible.map((t, i) => (            <ToastCard              key={t.id}              t={t}              layout={layouts[i]}              paused={expanded}              reduce={reduce}              onHeight={onHeight}              onClose={() => dismiss(t.id)}            />          ))}        </AnimatePresence>      </div>    </div>  );}

Add the theme tokens and the pending spin to your global.css.

global.css
.tst-root {
  --tst-text: #1b1b19;
  --tst-mute: #73736d;
  --tst-faint: #a6a6a0;
  --tst-panel: #ffffff;
  --tst-bezel: #f0f0ee;
  --tst-well: #f6f6f4;
  --tst-line: rgba(27, 27, 25, 0.08);
  --tst-px-off: rgba(27, 27, 25, 0.06);
  --tst-bezel-shadow: 0 22px 44px -18px rgba(27, 27, 25, 0.22),
    0 0 0 1px rgba(27, 27, 25, 0.05);
  --tst-panel-inset: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.dark .tst-root {
  --tst-text: #f3f3ef;
  --tst-mute: #a2a29a;
  --tst-faint: #6e6e67;
  --tst-panel: #1e1e1b;
  --tst-bezel: #161513;
  --tst-well: #262622;
  --tst-line: rgba(255, 255, 255, 0.07);
  --tst-px-off: rgba(245, 245, 245, 0.05);
  --tst-bezel-shadow: 0 22px 44px -18px rgba(0, 0, 0, 0.7),
    0 0 0 1px rgba(255, 255, 255, 0.05);
  --tst-panel-inset: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@keyframes tst-spin {
  to {
    transform: rotate(360deg);
  }
}
.tst-spin {
  animation: tst-spin 0.9s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .tst-spin {
    animation: none;
  }
}

Usage

Mount <Toaster /> once near the root of your app, then call toast from anywhere.

Example.tsx
import { toast, Toaster } from '@/components/ui/toast';
import { GitCompare } from 'lucide-react';

export default function Example() {
  return (
    <>
      <button
        onClick={() =>
          toast.success('Changes saved', {
            desc: 'Atlas redesign · 12 files',
            action: { label: 'View', icon: GitCompare },
          })
        }
      >
        Save
      </button>
      <Toaster />
    </>
  );
}

toast.promise choreographs a pending toast that resolves in place:

Promise.tsx
toast.promise(deploy(), {
  loading: 'Deploying to production',
  success: 'Deployed to production',
  error: 'Deploy failed',
  desc: 'atlas-web · commit 8f2a1c',
});

API

CallDescription
toast(input)Push a raw toast (title, desc?, type?, duration?, action?).
toast.success(title, o)Green success toast.
toast.error(title, o)Red error toast, held a little longer.
toast.info(title, o)Blue info toast.
toast.pending(title, o)Spinning toast that sticks until updated.
toast.promise(p, msg)Pending toast that resolves or rejects in place.
toast.dismiss(id)Remove a toast by id.
toast.update(id, patch)Patch a live toast (used to resolve pending ones).

Props

<Toaster /> accepts:

PropTypeDefaultDescription
maxnumber5How many toasts render in the pile at once.
classNamestring-Forwarded to the fixed overlay wrapper.
On this page0%