All patterns

Setup Complete

The last item checks itself off, the checklist clears, and a single confirmation takes its place.

onboardingpremiumcalmautomatic · finite · intermediate · ~1.9s
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.

335 lines · react + motion only
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Setup Complete
 *
 * The last item checks itself off and the whole list resolves into one
 * line. Deliberately restrained: no confetti, no particles, nothing
 * bounces — the reward for finishing setup is that the setup disappears.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `items`, `title`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CompletionCelebrationProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Checklist labels. The last one is the one being completed. */
  items?: string[];
  /** Headline of the resolved state. */
  title?: string;
  /** Line under the headline. */
  body?: string;
  /** Check and badge color. */
  accent?: string;
  /** Fires when the list has resolved into the confirmation. */
  onResolved?: () => void;
};

type VariantConfig = {
  /** When the list starts clearing, in seconds. */
  clearAt: number;
  /** Gap between rows leaving, in seconds. */
  rowStagger: number;
  /** How far the confirmation rises into place, in px. */
  rise: number;
  /** One slow pass of light across the confirmation. 0 turns it off. */
  shimmerSeconds: number;
};

// Quality rule, and the whole point of this pattern: a celebration that
// overacts reads as cheap, and it reads that way every single time after
// the first. So the vocabulary here is deliberately poor — a check that
// draws, a list that clears, one optional pass of light. No spring
// anywhere, because there is nothing here that should overshoot.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The plainest resolve: list out, line in, no light at all.
  subtle: { clearAt: 0.7, rowStagger: 0.03, rise: 5, shimmerSeconds: 0 },
  // One soft pass of light after the line lands. The all-purpose setting.
  default: { clearAt: 0.85, rowStagger: 0.04, rise: 7, shimmerSeconds: 0.9 },
  // Longer beats and a slower sweep — still no bounce, because a
  // milestone that springs is a milestone nobody trusts twice.
  playful: { clearAt: 1, rowStagger: 0.055, rise: 9, shimmerSeconds: 1.15 },
};

/** Neutral surfaces are mixed from the inherited text color, so the card
 *  reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SAMPLE_ITEMS = [
  "Name your workspace",
  "Invite your team",
  "Connect your calendar",
  "Create the first document",
];

/** Fixed row and panel geometry, so the collapse is a known distance
 *  rather than a measurement taken mid-animation. */
const ROW_HEIGHT = 30;
const CONFIRM_HEIGHT = 54;

export default function CompletionCelebration({
  variant = "default",
  items = SAMPLE_ITEMS,
  title = "Workspace ready",
  body = "Every step is done. All of them stay editable in settings.",
  accent = "#3E9B6B",
  onResolved,
}: CompletionCelebrationProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const listHeight = items.length * ROW_HEIGHT;
  const lastIndex = items.length - 1;
  const confirmAt = cfg.clearAt + 0.3;

  // Reduced motion: the list is already resolved. The information — you
  // are finished — is identical; only the resolving is dropped.
  if (reduceMotion) {
    return (
      <Frame>
        <Header count={`${items.length} of ${items.length}`} />
        <div style={{ height: CONFIRM_HEIGHT, marginTop: 10 }}>
          <Confirmation
            accent={accent}
            title={title}
            body={body}
            shimmer={null}
          />
        </div>
      </Frame>
    );
  }

  return (
    <Frame>
      <Header count={`${items.length} of ${items.length}`} />

      <motion.div
        initial={{ height: listHeight }}
        animate={{ height: CONFIRM_HEIGHT }}
        // A real size change, so it is allowed to be a height tween —
        // kept short and eased, and it is the only one in the file.
        transition={{ delay: cfg.clearAt + 0.1, duration: 0.38, ease: "easeInOut" }}
        onAnimationComplete={() => onResolved?.()}
        style={{ position: "relative", marginTop: 10, overflow: "hidden" }}
      >
        <div style={{ position: "absolute", left: 0, right: 0, top: 0 }}>
          {items.map((item, index) => (
            <motion.div
              key={item}
              initial={{ opacity: 1, y: 0 }}
              animate={{ opacity: 0, y: -6 }}
              transition={{
                delay: cfg.clearAt + index * cfg.rowStagger,
                duration: 0.26,
                ease: "easeOut",
              }}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 9,
                height: ROW_HEIGHT,
                fontSize: 13,
              }}
            >
              <span
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 17,
                  height: 17,
                  borderRadius: 999,
                  border: `1.5px solid ${
                    index === lastIndex ? accent : tone(20)
                  }`,
                  background: index === lastIndex ? "transparent" : tone(10),
                }}
              >
                <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
                  {index === lastIndex ? (
                    // The last item completes in front of the reader:
                    // the stroke draws itself, once, and that is the
                    // entire celebration.
                    <motion.path
                      d="M3.6 8.4 6.6 11.4 12.4 5.2"
                      stroke={accent}
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      initial={{ pathLength: 0 }}
                      animate={{ pathLength: 1 }}
                      transition={{ delay: 0.18, duration: 0.34, ease: "easeOut" }}
                    />
                  ) : (
                    <path
                      d="M3.6 8.4 6.6 11.4 12.4 5.2"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      opacity="0.4"
                    />
                  )}
                </svg>
              </span>
              <span style={{ opacity: index === lastIndex ? 0.95 : 0.5 }}>
                {item}
              </span>
            </motion.div>
          ))}
        </div>

        <motion.div
          initial={{ opacity: 0, y: cfg.rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ delay: confirmAt, duration: 0.34, ease: "easeOut" }}
          style={{ position: "absolute", left: 0, right: 0, top: 0 }}
        >
          <Confirmation
            accent={accent}
            title={title}
            body={body}
            shimmer={
              cfg.shimmerSeconds
                ? { delay: confirmAt + 0.25, duration: cfg.shimmerSeconds }
                : null
            }
          />
        </motion.div>
      </motion.div>
    </Frame>
  );
}

function Frame({ children }: { children: ReactNode }) {
  return (
    <div
      style={{
        width: 320,
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        boxSizing: "border-box",
      }}
    >
      {children}
    </div>
  );
}

function Header({ count }: { count: string }) {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        fontSize: 11,
        fontWeight: 650,
        letterSpacing: 0.4,
        textTransform: "uppercase",
        opacity: 0.4,
      }}
    >
      <span>Set up</span>
      <span>{count}</span>
    </div>
  );
}

function Confirmation({
  accent,
  title,
  body,
  shimmer,
}: {
  accent: string;
  title: string;
  body: string;
  shimmer: { delay: number; duration: number } | null;
}) {
  return (
    <div
      style={{
        position: "relative",
        display: "flex",
        alignItems: "center",
        gap: 11,
        height: CONFIRM_HEIGHT,
        padding: "0 12px",
        borderRadius: 12,
        border: `1px solid ${tone(12)}`,
        background: tone(7),
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      <span
        aria-hidden
        style={{
          display: "grid",
          placeItems: "center",
          flex: "none",
          width: 26,
          height: 26,
          borderRadius: 999,
          background: accent,
        }}
      >
        <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
          <path
            d="M3.6 8.4 6.6 11.4 12.4 5.2"
            stroke="#ffffff"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span style={{ minWidth: 0 }}>
        <span style={{ display: "block", fontSize: 14, fontWeight: 650 }}>
          {title}
        </span>
        <span
          style={{
            display: "block",
            marginTop: 2,
            fontSize: 11.5,
            lineHeight: 1.4,
            opacity: 0.55,
          }}
        >
          {body}
        </span>
      </span>

      {shimmer && (
        // One pass, then gone. A shimmer that repeats turns a
        // confirmation into a loading state.
        <motion.span
          aria-hidden
          initial={{ x: "-130%" }}
          animate={{ x: "130%" }}
          transition={{
            delay: shimmer.delay,
            duration: shimmer.duration,
            ease: "easeInOut",
          }}
          style={{
            position: "absolute",
            top: 0,
            bottom: 0,
            width: "70%",
            background: `linear-gradient(100deg, transparent, ${tone(13)}, transparent)`,
            pointerEvents: "none",
          }}
        />
      )}
    </div>
  );
}

About this pattern

The end of a setup flow, played straight. The final item's stroke draws itself, the four rows fade up and out in quick order, the panel eases down to one line, and a confirmation settles into the space the list used to occupy. The only ornament is a single slow pass of light across that line, and even that is off in the quietest variant. No confetti, no particles, no bounce: a milestone that overacts reads as cheap, and it reads that way every time after the first — the reward for finishing setup is that the setup is gone. The height change is the one tween here, because a list collapsing genuinely is a size change; everything else is opacity and a few pixels of travel.

Setup completionOnboarding flowChecklist resolutionMilestone confirmation

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 finished activation list is replaced by a single confirmation row rather than a fanfare.

Related patterns