All patterns

Toast Slide In

A toast slides in from the edge, rests while a thin line counts down, then leaves the way it came.

feedbackminimalfriendlyautomatic · finite · starter · ~4.2s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

243 lines · react + motion only
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Toast Slide In
 *
 * A toast that slides in from the edge, rests while a thin progress
 * line runs out, then leaves the way it came.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The panel follows the host app's color scheme, so it lands light on a
 * light page and dark on a dark one.
 * Works with zero props; tune via `variant`, `from`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ToastSlideInProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Edge the toast travels from — match it to where the toast is docked. */
  from?: "bottom" | "top" | "right";
  /** Headline line. */
  title?: string;
  /** Supporting line. Pass an empty string for a single-line toast. */
  description?: string;
  /** How long the toast rests before it leaves, in ms. Also the progress line. */
  holdMs?: number;
  /** Fires once the toast has finished leaving. */
  onDismiss?: () => void;
};

type VariantConfig = {
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  exitDuration: number;
};

// Quality rule: a toast interrupts whatever the user was doing, so it has
// to land flat — every spring here sits at or above a 0.8 damping ratio.
// Variants differ in how far the toast travels and how fast it arrives,
// never in how many times it bounces.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Short travel, no overshoot whatsoever. For toasts that fire often
  // enough that arrival should barely register.
  subtle: {
    travel: 12,
    spring: { type: "spring", stiffness: 480, damping: 44 },
    exitDuration: 0.16,
  },
  // Enough travel to read as "this arrived", one soft settle. All-purpose.
  default: {
    travel: 20,
    spring: { type: "spring", stiffness: 380, damping: 34 },
    exitDuration: 0.18,
  },
  // Further and quicker — noticeable across a large screen without
  // turning rubbery.
  playful: {
    travel: 30,
    spring: { type: "spring", stiffness: 420, damping: 34 },
    exitDuration: 0.2,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral for the border and the icon well: mixing the
 *  text color in scope with `transparent` lands correctly on a light
 *  surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ToastSlideIn({
  variant = "default",
  from = "bottom",
  title = "Invite sent",
  description = "maya@northwind.app can now edit this project.",
  holdMs = 3600,
  onDismiss,
}: ToastSlideInProps) {
  const [open, setOpen] = useState(true);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  useEffect(() => {
    const timer = setTimeout(() => setOpen(false), holdMs);
    return () => clearTimeout(timer);
  }, [holdMs]);

  // Reduced motion: the toast still arrives and still leaves, it just
  // doesn't travel. The progress line stays either way — "how long you
  // have left to act" is information, not decoration.
  const offset = reduceMotion
    ? { x: 0, y: 0 }
    : from === "right"
      ? { x: cfg.travel, y: 0 }
      : { x: 0, y: from === "top" ? -cfg.travel : cfg.travel };

  return (
    <AnimatePresence onExitComplete={onDismiss}>
      {open && (
        <motion.div
          role="status"
          aria-live="polite"
          initial={{ opacity: 0, ...offset }}
          animate={{ opacity: 1, x: 0, y: 0 }}
          exit={{
            opacity: 0,
            ...offset,
            // Leaving is not an event worth watching: it undercuts the
            // arrival on a plain ease-in, faster than it came.
            transition: { duration: cfg.exitDuration, ease: "easeIn" },
          }}
          transition={
            reduceMotion
              ? { duration: 0.16, ease: "easeOut" }
              : {
                  ...cfg.spring,
                  // Opacity on its own quick curve; springing it looks muddy.
                  opacity: { duration: 0.16, ease: "easeOut" },
                }
          }
          // Translate + opacity only. Scaling the panel would scale the
          // message text with it, which is the one thing text must never do.
          style={{
            position: "relative",
            width: 320,
            display: "flex",
            alignItems: "flex-start",
            gap: 11,
            padding: "13px 13px 15px",
            borderRadius: 14,
            // A toast covers page content, so this surface has to be
            // opaque — a translucent panel would let the text underneath
            // read through it. `Canvas`/`CanvasText` are the CSS system
            // colors for page background and page text: they follow the
            // host app's color scheme, so the toast is light in a light
            // app and dark in a dark one, and the pair is legible either
            // way. Everything inside then mixes from `currentColor`.
            background: "Canvas",
            color: "CanvasText",
            border: `1px solid ${tone(14)}`,
            boxShadow: "0 12px 32px rgba(0,0,0,0.2)",
            overflow: "hidden",
          }}
        >
          <span
            aria-hidden
            style={{
              flexShrink: 0,
              width: 22,
              height: 22,
              marginTop: 1,
              borderRadius: "50%",
              background: `color-mix(in srgb, ${ACCENT} 18%, transparent)`,
              display: "grid",
              placeItems: "center",
            }}
          >
            <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
              <path
                d="M3.4 8.6 6.4 11.6 12.6 5.2"
                stroke={ACCENT}
                strokeWidth="1.9"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </span>

          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.35 }}>
              {title}
            </div>
            {description ? (
              <div
                style={{
                  fontSize: 12.5,
                  lineHeight: 1.45,
                  opacity: 0.55,
                  marginTop: 2,
                }}
              >
                {description}
              </div>
            ) : null}
          </div>

          <button
            type="button"
            onClick={() => setOpen(false)}
            aria-label="Dismiss notification"
            style={{
              flexShrink: 0,
              width: 22,
              height: 22,
              marginTop: 1,
              padding: 0,
              display: "grid",
              placeItems: "center",
              background: "none",
              border: 0,
              borderRadius: 6,
              color: "inherit",
              opacity: 0.45,
              cursor: "pointer",
            }}
          >
            <svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
              <path
                d="M4 4 12 12M12 4 4 12"
                stroke="currentColor"
                strokeWidth="1.7"
                strokeLinecap="round"
              />
            </svg>
          </button>

          {/* Time remaining, drawn as a transform: scaleX on a pinned line
              costs nothing per frame, where animating width would relayout
              the toast 60 times a second. Linear because a progress line
              that eases is lying about the clock. */}
          <motion.div
            aria-hidden
            initial={{ scaleX: 1 }}
            animate={{ scaleX: 0 }}
            transition={{ duration: holdMs / 1000, ease: "linear" }}
            style={{
              position: "absolute",
              left: 0,
              right: 0,
              bottom: 0,
              height: 2,
              transformOrigin: "0% 50%",
              background: ACCENT,
              opacity: 0.85,
            }}
          />
        </motion.div>
      )}
    </AnimatePresence>
  );
}

About this pattern

Transient feedback that must not steal the cursor: an action succeeded, a job finished, an undo window is open. The panel travels in from the edge it is docked against, holds while a hairline progress bar runs out, then retreats faster than it arrived. The countdown line is the part most toasts skip, and it is the part that makes the toast feel fair — the user can see how long they have to hit Undo instead of guessing. Motion is translate-and-fade only, so the message text never deforms on the way in.

Changes savedUndo promptBackground job finishedSync error notice

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    A transient bar that holds an undo window open, then withdraws unasked.

Related patterns