All patterns

Inbox Zero

A cleared list comes to rest: a shelf draws out, the mark lands on it, and nothing moves again.

empty-statescalmelegantautomatic · finite · starter · ~1.1s
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.

225 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Inbox Zero
 *
 * A finished state, not a waiting one. A hairline shelf draws out from
 * the centre, the tray comes down onto it and stops, and the words fade
 * after both have settled. Then nothing moves again — the whole point of
 * a cleared inbox is that it is asking nothing of you, and anything that
 * loops here would quietly undo that.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type InboxZeroRestProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Headline under the mark. */
  title?: string;
  /** One supporting sentence. Keep it short — this state is quiet. */
  subtitle?: string;
  /** Small closing line, e.g. when the last item was cleared. */
  footnote?: string;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Seconds the shelf takes to draw out from the centre. */
  shelfSeconds: number;
  /** px the tray descends onto the shelf. */
  drop: number;
  /** Seconds a line of text takes to fade in. */
  fadeSeconds: number;
  /** Seconds between the tray landing and the first line of text. */
  textDelay: number;
  traySpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the tray is the only sprung element and every variant
// sits above 0.89 damping ratio (damping / 2√stiffness), so it lands
// once and stays landed. A rest state that wobbles reads as unfinished.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost imperceptible. For a mail column that empties several times
  // a day and should not announce it.
  subtle: {
    shelfSeconds: 0.45,
    drop: 5,
    fadeSeconds: 0.26,
    textDelay: 0.22,
    traySpring: { type: "spring", stiffness: 480, damping: 46 },
  },
  // The all-purpose setting: unhurried, clearly over when it is over.
  default: {
    shelfSeconds: 0.6,
    drop: 9,
    fadeSeconds: 0.34,
    textDelay: 0.3,
    traySpring: { type: "spring", stiffness: 380, damping: 38 },
  },
  // Slowest, for a full-height inbox where reaching zero is worth a
  // beat of stillness before the words arrive.
  playful: {
    shelfSeconds: 0.78,
    drop: 13,
    fadeSeconds: 0.4,
    textDelay: 0.38,
    traySpring: { type: "spring", stiffness: 300, damping: 31 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a shelf, a halo and line art that land correctly
 *  on light and dark pages alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function InboxZeroRest({
  variant = "default",
  title = "Inbox zero",
  subtitle = "Nothing needs you right now.",
  footnote = "Last message cleared at 4:12 PM",
  width = 320,
}: InboxZeroRestProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion keeps the arrival order and drops the travel: the
  // shelf is simply there, the tray simply appears, the words follow.
  const drop = reduceMotion ? 0 : cfg.drop;
  const fade = (delay: number) => ({
    duration: cfg.fadeSeconds,
    ease: "easeOut" as const,
    delay,
  });

  return (
    <div
      style={{
        width,
        boxSizing: "border-box",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        textAlign: "center",
        padding: "30px 22px 28px",
      }}
    >
      <div
        style={{
          position: "relative",
          display: "grid",
          placeItems: "center",
          width: 132,
          height: 78,
          marginBottom: 16,
        }}
      >
        {/* A single wash of light behind the mark. It fades in once and
            holds — no breathing, because a breath would read as work
            still happening somewhere. */}
        <motion.div
          aria-hidden
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: cfg.fadeSeconds * 2.2, ease: "easeOut" }}
          style={{
            position: "absolute",
            width: 132,
            height: 78,
            borderRadius: "50%",
            background: `radial-gradient(closest-side, ${tone(9)}, transparent)`,
          }}
        />

        <motion.div
          aria-hidden
          initial={{ opacity: 0, y: drop }}
          animate={{ opacity: 1, y: 0 }}
          transition={
            reduceMotion
              ? { duration: 0.22, ease: "easeOut" }
              : {
                  ...cfg.traySpring,
                  opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
                }
          }
          style={{ position: "relative", lineHeight: 0, marginBottom: 6 }}
        >
          <TrayMark />
        </motion.div>

        {/* The shelf grows from its centre, so the mark reads as coming
            to rest on something rather than floating. */}
        <motion.div
          aria-hidden
          initial={{ opacity: 0, scaleX: reduceMotion ? 1 : 0.12 }}
          animate={{ opacity: 1, scaleX: 1 }}
          transition={{
            duration: reduceMotion ? 0.22 : cfg.shelfSeconds,
            ease: [0.16, 1, 0.3, 1],
          }}
          style={{
            position: "absolute",
            bottom: 4,
            width: 104,
            height: 1,
            background: tone(16),
          }}
        />
      </div>

      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={fade(cfg.textDelay)}
        style={{ fontSize: 15, fontWeight: 640 }}
      >
        {title}
      </motion.div>

      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 0.6 }}
        transition={fade(cfg.textDelay + 0.07)}
        style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5 }}
      >
        {subtitle}
      </motion.div>

      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 0.38 }}
        transition={fade(cfg.textDelay + 0.15)}
        style={{ fontSize: 11.5, marginTop: 12 }}
      >
        {footnote}
      </motion.div>
    </div>
  );
}

/** Line art authored inline: an empty tray, stroked in `currentColor` at
 *  low opacity so it needs no asset and inherits the page theme. */
function TrayMark() {
  return (
    <svg width="56" height="52" viewBox="0 0 56 52" fill="none" aria-hidden>
      <g
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.34"
      >
        {/* Sides feeding down into the tray. */}
        <path d="M9 30 L15.5 13 H40.5 L47 30" />
        {/* The tray itself, with the lip a message would drop through. */}
        <path d="M9 30 h12 l3.5 4.5 h7 L35 30 h12 v8 a3.5 3.5 0 0 1 -3.5 3.5 H12.5 A3.5 3.5 0 0 1 9 38 z" />
      </g>
    </svg>
  );
}

About this pattern

The difference between an empty list and a finished one is whether the motion stops. A hairline shelf draws out from its centre, the tray descends onto it and settles once, the words fade in after both are still — and then the block holds, permanently. No breathing placeholder, no drifting mark, nothing that could be mistaken for work still in progress. This is the state a person sees after clearing the last thing in a queue, and it should feel like an ending rather than a pause. Reduced motion keeps the arrival order and drops the travel.

Cleared mail columnQueue with nothing leftTask list finishedReview pile emptied

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
    Nothing left to readWhen there is something to show, it appears here.
    Inbox

    An emptied inbox resolves into a still mark and a single line of copy under the filter row.

Related patterns