All patterns

Signup Progress Meter

A slim meter advances as each account requirement is satisfied, and every row marks itself as it passes.

authenticationfriendlyenergeticautomatic · finite · starter · ~2.6s
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.

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

/**
 * Vibary · Signup Progress Meter
 *
 * A slim meter advances as each account requirement is satisfied, and
 * every satisfied row marks itself as the meter passes it.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `requirements`, `stepMs`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SignupProgressMeterProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Card heading. */
  title?: string;
  /** Rows, in the order they get satisfied. */
  requirements?: string[];
  /** Beat between one requirement being satisfied and the next, in ms. */
  stepMs?: number;
  /** Meter and mark color. */
  accent?: string;
  /** Fires when the last requirement is satisfied. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How the fill travels to its new width. */
  fill: { type: "spring"; stiffness: number; damping: number };
  /** Seconds a row's mark takes to stroke itself in. */
  mark: number;
  /** How far a satisfied row shifts toward the reader, in px. */
  nudge: number;
  rowSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: a meter is telling the reader something factual, so it
// arrives at its value and stops — every spring here sits at or near a
// ratio of 1.0, well past the 0.8 floor, because a bar that overshoots
// 60% has briefly reported 68%. The percentage is text and only
// crossfades; it never scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and matter-of-fact. For setup checklists shown on every visit
  // until they are done.
  subtle: {
    fill: { type: "spring", stiffness: 450, damping: 43 },
    mark: 0.16,
    nudge: 0,
    rowSpring: { type: "spring", stiffness: 580, damping: 47 },
  },
  // The bar visibly travels and each row acknowledges itself. The
  // all-purpose setting.
  default: {
    fill: { type: "spring", stiffness: 300, damping: 34 },
    mark: 0.26,
    nudge: 2,
    rowSpring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A longer travel with a slower mark, for a first-run setup that is
  // meant to feel like progress rather than admin.
  playful: {
    fill: { type: "spring", stiffness: 210, damping: 29 },
    mark: 0.36,
    nudge: 3,
    rowSpring: { type: "spring", stiffness: 390, damping: 36 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields a surface, track or border correctly toned
 *  in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_REQUIREMENTS = [
  "Email confirmed",
  "Password set",
  "Display name added",
  "Workspace named",
];

export default function SignupProgressMeter({
  variant = "default",
  title = "Finish setting up",
  requirements = DEFAULT_REQUIREMENTS,
  stepMs = 620,
  accent = "#5B5BD6",
  onComplete,
}: SignupProgressMeterProps) {
  const [satisfied, setSatisfied] = useState(0);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const total = requirements.length;
  const percent = total === 0 ? 0 : Math.round((satisfied / total) * 100);

  // The requirements satisfy themselves on a beat so the pattern shows
  // its own arc. Drive `setSatisfied` from your real account state.
  useEffect(() => {
    if (satisfied >= total) return;
    const timer = setTimeout(
      () => setSatisfied((current) => current + 1),
      satisfied === 0 ? stepMs * 0.7 : stepMs
    );
    return () => clearTimeout(timer);
  }, [satisfied, total, stepMs]);

  useEffect(() => {
    if (total > 0 && satisfied >= total) onComplete?.();
  }, [satisfied, total, onComplete]);

  return (
    <div
      style={{
        width: 288,
        display: "flex",
        flexDirection: "column",
        gap: 12,
        padding: 18,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        color: "inherit",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          gap: 12,
        }}
      >
        <span style={{ fontSize: 14.5, fontWeight: 650 }}>{title}</span>

        {/* The readout sits in a fixed slot with tabular figures, so the
            digits change without the heading beside them shifting. It
            crossfades — a number that scales while it counts stops
            reading as data. */}
        <span
          style={{
            position: "relative",
            width: 40,
            height: 16,
            fontSize: 12,
            fontWeight: 600,
            fontVariantNumeric: "tabular-nums",
            textAlign: "right",
            opacity: 0.65,
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={percent}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ position: "absolute", inset: 0 }}
            >
              {percent}%
            </motion.span>
          </AnimatePresence>
        </span>
      </div>

      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={total}
        aria-valuenow={satisfied}
        aria-label={`${satisfied} of ${total} steps done`}
        style={{
          height: 5,
          borderRadius: 3,
          background: tone(11),
          overflow: "hidden",
        }}
      >
        {/* scaleX from the left edge rather than an animated width: the
            fill stays on the compositor and the track never reflows. */}
        <motion.div
          initial={{ scaleX: 0 }}
          animate={{ scaleX: total === 0 ? 0 : satisfied / total }}
          transition={
            reduceMotion ? { duration: 0.12, ease: "easeOut" } : cfg.fill
          }
          style={{
            height: "100%",
            borderRadius: 3,
            background: accent,
            transformOrigin: "left",
          }}
        />
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
        {requirements.map((requirement, index) => {
          const done = index < satisfied;
          return (
            <motion.div
              key={requirement}
              animate={{ x: done && !reduceMotion ? cfg.nudge : 0 }}
              transition={reduceMotion ? { duration: 0 } : cfg.rowSpring}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 9,
                padding: "6px 0",
                fontSize: 12.5,
              }}
            >
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 18,
                  height: 18,
                  flex: "0 0 auto",
                  borderRadius: "50%",
                  border: `1.5px solid ${done ? accent : tone(20)}`,
                  color: accent,
                  // The ring recolors as a state change rather than an
                  // animated property, keeping the loop transform-only.
                  transition: "border-color 200ms ease-out",
                }}
              >
                <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
                  <motion.path
                    d="M3.6 8.4 6.6 11.3 12.4 5"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    initial={false}
                    animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}
                    transition={{
                      duration: reduceMotion ? 0.1 : cfg.mark,
                      ease: "easeOut",
                    }}
                  />
                </svg>
              </span>

              <span
                style={{
                  opacity: done ? 0.95 : 0.5,
                  fontWeight: done ? 600 : 400,
                  transition: "opacity 220ms ease-out",
                }}
              >
                {requirement}
              </span>
            </motion.div>
          );
        })}
      </div>

      <button
        type="button"
        disabled={satisfied < total}
        style={{
          width: "100%",
          padding: "9px 14px",
          fontSize: 13,
          fontWeight: 600,
          fontFamily: "inherit",
          color: satisfied < total ? "inherit" : "#ffffff",
          background: satisfied < total ? tone(8) : accent,
          border: satisfied < total ? `1px solid ${tone(14)}` : "none",
          borderRadius: 9,
          opacity: satisfied < total ? 0.5 : 1,
          cursor: satisfied < total ? "default" : "pointer",
          transition: "background-color 220ms ease-out, opacity 220ms ease-out",
        }}
      >
        {satisfied < total ? "Keep going" : "Enter your workspace"}
      </button>
    </div>
  );
}

About this pattern

The account-setup checklist, paced so finishing it feels like momentum rather than admin. The fill scales from its left edge instead of animating width, which keeps it on the compositor and stops the track reflowing, and the spring is damped to about a ratio of one: a bar that overshoots sixty percent has briefly reported sixty-eight, and this bar is making a factual claim. Each requirement acknowledges itself as the meter reaches it — the ring recolors, the mark strokes in, the row shifts a couple of pixels toward the reader — while the percentage crossfades in a fixed slot with tabular figures so no digit change moves the heading beside it.

Account setup checklistProfile completeness promptFirst-run onboarding tasksWorkspace creation requirements

Where it shows up

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

  • Finish setting upTwo left before you can take payments
    Verify your email
    Add a business address
    Connect a bank account
    Turn on two-factor
    Connect bank
    Setup checklist

    A requirements list with a single bar reporting how close the account is to live.

Related patterns