All patterns

Archive Empty

One fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.

empty-statesminimalcalmautomatic · finite · starter · ~0.7s
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.

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

/**
 * Vibary · Archive Empty
 *
 * The quietest surface in the product, and the brief is restraint: one
 * fade, and a lid coming down three pixels onto a box. Nothing draws,
 * nothing loops, nothing waits for input. An archive with nothing in it
 * is not a problem to solve — it is a drawer that was never filled, and
 * the state should be over before the reader has finished reading it.
 *
 * 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 ArchiveEmptyRestProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** The single line that carries the state. */
  title?: string;
  /** Optional second line. Pass an empty string to drop it. */
  message?: string;
  /** Block width — px number or any CSS length. */
  width?: number | string;
  /** Vertical padding — the amount of quiet around the mark. */
  padding?: number | string;
};

type VariantConfig = {
  /** px the lid descends onto the box. Deliberately tiny. */
  lidDrop: number;
  /** Seconds the whole block takes to fade up. */
  fadeSeconds: number;
  /** Seconds between the mark and the words. */
  textDelay: number;
  lidSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: one spring, on a lid, at or above 0.98 damping ratio
// (damping / 2√stiffness) — effectively a soft stop. In a state this
// quiet even a single visible overshoot would be the loudest thing on
// the screen.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a motion at all. For an archive tab visited by accident.
  subtle: {
    lidDrop: 2,
    fadeSeconds: 0.32,
    textDelay: 0.1,
    lidSpring: { type: "spring", stiffness: 460, damping: 46 },
  },
  // The all-purpose setting: the lid lands, the words follow.
  default: {
    lidDrop: 3,
    fadeSeconds: 0.42,
    textDelay: 0.14,
    lidSpring: { type: "spring", stiffness: 340, damping: 36 },
  },
  // Slowest, for a full-page archive where the stillness is the design.
  playful: {
    lidDrop: 5,
    fadeSeconds: 0.54,
    textDelay: 0.2,
    lidSpring: { type: "spring", stiffness: 260, damping: 32 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps the mark correct on light and dark pages. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ArchiveEmptyRest({
  variant = "default",
  title = "Nothing archived",
  message = "Anything you archive is kept here.",
  width = 320,
  padding = "38px 20px",
}: ArchiveEmptyRestProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const fade = (delay: number) => ({
    duration: cfg.fadeSeconds,
    ease: "easeOut" as const,
    delay,
  });

  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={fade(0)}
      style={{
        width,
        boxSizing: "border-box",
        padding,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        textAlign: "center",
      }}
    >
      <div aria-hidden style={{ lineHeight: 0, marginBottom: 13 }}>
        <svg width="46" height="44" viewBox="0 0 48 46" fill="none">
          {/* The lid is the only thing that moves in this entire file. It
              travels inside a group, so the rect keeps its own geometry
              attributes and only the group is transformed. */}
          <motion.g
            initial={{ y: reduceMotion ? 0 : -cfg.lidDrop }}
            animate={{ y: 0 }}
            transition={
              reduceMotion ? { duration: 0.2, ease: "easeOut" } : cfg.lidSpring
            }
          >
            <rect
              x="8.75"
              y="10.75"
              width="30.5"
              height="7.5"
              rx="2.5"
              stroke="currentColor"
              strokeWidth="1.5"
              opacity="0.34"
            />
          </motion.g>
          <path
            d="M12 18.5h24v14a3.5 3.5 0 0 1-3.5 3.5h-17A3.5 3.5 0 0 1 12 32.5z"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinejoin="round"
            opacity="0.34"
          />
          <path
            d="M20.5 25h7"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            opacity="0.22"
          />
        </svg>
      </div>

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

      {message ? (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 0.45 }}
          transition={fade(cfg.textDelay + 0.07)}
          style={{ fontSize: 12, marginTop: 5 }}
        >
          {message}
        </motion.div>
      ) : null}

      {/* A hairline closing the block, at the width of the words. It fades
          with everything else and then holds — the visual full stop. */}
      <motion.div
        aria-hidden
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={fade(cfg.textDelay + 0.14)}
        style={{
          width: 44,
          height: 1,
          marginTop: 18,
          background: tone(14),
        }}
      />
    </motion.div>
  );
}

About this pattern

The quietest surface in a product, treated as a design constraint rather than an afterthought. The block fades up as one piece, a lid comes down a few pixels onto the box, the words follow, and a hairline closes the paragraph. That is the entire vocabulary: no drawing, no loop, no pulse, nothing waiting to be pressed. An archive with nothing in it is not a problem the reader needs to solve, and every extra movement here would imply otherwise. Reduced motion removes the lid's travel and keeps the fade, which is almost the same state.

Empty archive folderTrash with nothing in itRarely visited tabSecondary list with no history

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 empty archive shows a single centred line with no action attached to it.

Related patterns