All patterns

Resume Where You Left

A welcome-back card settles, draws the setup already finished, then marks the step waiting to be picked up.

onboardingcalmpremiumautomatic · finite · starter · ~1.4s
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.

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

/**
 * Vibary · Resume Where You Left
 *
 * Coming back to something half-finished. The card settles, the work
 * already done draws itself along the track, and only then does the
 * unfinished step take the accent — so the person is shown their credit
 * before they are shown their homework.
 *
 * The finished steps do not animate. They happened on a previous visit
 * and re-performing them would be the product taking credit twice; they
 * are simply there when the card arrives, which is what leaves the next
 * step somewhere to stand out.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Card, track and step rows are mixed from the inherited text color; the
 * accent is semantic and stays literal.
 * Works with zero props; tune via `variant`, `steps`, `greeting`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ResumeStep = {
  id: string;
  label: string;
  /** Set on the steps finished before this visit. */
  done?: boolean;
};

export type ReturningUserResumeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Line at the top of the card. */
  greeting?: string;
  /** What was left unfinished. */
  subtitle?: string;
  /** Your own setup steps. The embedded sample is used when omitted. */
  steps?: ResumeStep[];
  /** Wording of the chip on the step being returned to. */
  resumeLabel?: string;
  /** Wording of the action. */
  actionLabel?: string;
  /** Accent colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires when the action is pressed. */
  onResume?: () => void;
};

type VariantConfig = {
  /** Card entry spring. */
  spring: { type: "spring"; stiffness: number; damping: number };
  /** px the card rises on entry. */
  lift: number;
  /** Beat before the earned progress draws. */
  trackDelay: number;
  /** How long the earned progress takes to draw. */
  trackFill: number;
  /** Extra beat before the unfinished step is marked. */
  focusGap: number;
};

// Damping ratios of 0.85 and up: the card arrives and stops. This is the
// first thing somebody sees on returning to an unfinished job, so it has
// to feel like being welcomed back rather than being sold to. Variants
// change pace and travel, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost immediate. For a returning-session banner seen daily.
  subtle: {
    spring: { type: "spring", stiffness: 520, damping: 44 },
    lift: 6,
    trackDelay: 0.12,
    trackFill: 0.36,
    focusGap: 0.08,
  },
  // The all-purpose setting: credit first, then the next thing to do.
  default: {
    spring: { type: "spring", stiffness: 400, damping: 34 },
    lift: 10,
    trackDelay: 0.22,
    trackFill: 0.54,
    focusGap: 0.14,
  },
  // A slower welcome, for a re-entry screen after a long absence.
  playful: {
    spring: { type: "spring", stiffness: 320, damping: 30 },
    lift: 14,
    trackDelay: 0.3,
    trackFill: 0.72,
    focusGap: 0.2,
  },
};

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

const SAMPLE_STEPS: ResumeStep[] = [
  { id: "workspace", label: "Create your workspace", done: true },
  { id: "team", label: "Invite your team", done: true },
  { id: "billing", label: "Add billing details" },
  { id: "source", label: "Connect a data source" },
];

export default function ReturningUserResume({
  variant = "default",
  greeting = "Welcome back, Robin",
  subtitle = "You were partway through setting up Northwind.",
  steps = SAMPLE_STEPS,
  resumeLabel = "Pick up here",
  actionLabel = "Resume setup",
  accent = "#4A7A8C",
  onResume,
}: ReturningUserResumeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const doneCount = steps.filter((step) => step.done).length;
  const earned = steps.length ? doneCount / steps.length : 0;
  const nextIndex = steps.findIndex((step) => !step.done);
  const focusDelay = cfg.trackDelay + cfg.trackFill + cfg.focusGap;

  return (
    <motion.div
      initial={still ? { opacity: 0 } : { opacity: 0, y: cfg.lift }}
      animate={{ opacity: 1, y: 0 }}
      transition={
        still
          ? { duration: 0.2, ease: "easeOut" }
          : { ...cfg.spring, opacity: { duration: 0.22, ease: "easeOut" } }
      }
      style={{
        width: 300,
        boxSizing: "border-box",
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div style={{ fontSize: 15, fontWeight: 670, letterSpacing: "-0.015em" }}>
        {greeting}
      </div>
      <p style={{ margin: "5px 0 14px", fontSize: 11.5, lineHeight: 1.5, color: tone(54) }}>
        {subtitle}
      </p>

      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <span
          style={{
            position: "relative",
            flex: 1,
            height: 6,
            borderRadius: 999,
            background: tone(9),
            overflow: "hidden",
          }}
        >
          {/* The bar draws the credit that already exists. It is not a
              celebration and it does not overshoot — it stops on the
              fraction the person genuinely finished last time. */}
          <motion.span
            aria-hidden
            initial={{ scaleX: still ? earned : 0 }}
            animate={{ scaleX: earned }}
            transition={{
              duration: still ? 0 : cfg.trackFill,
              delay: still ? 0 : cfg.trackDelay,
              ease: [0.32, 0.72, 0.3, 1],
            }}
            style={{
              position: "absolute",
              inset: 0,
              transformOrigin: "left center",
              borderRadius: 999,
              background: accent,
            }}
          />
        </span>
        <motion.span
          initial={{ opacity: still ? 1 : 0 }}
          animate={{ opacity: 1 }}
          transition={{
            duration: still ? 0.2 : 0.3,
            delay: still ? 0 : cfg.trackDelay + cfg.trackFill * 0.7,
            ease: "easeOut",
          }}
          style={{
            fontSize: 10.5,
            fontWeight: 620,
            color: tone(52),
            fontVariantNumeric: "tabular-nums",
            whiteSpace: "nowrap",
          }}
        >
          {`${doneCount} of ${steps.length} done`}
        </motion.span>
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 2, marginTop: 14 }}>
        {steps.map((step, index) => {
          const isNext = index === nextIndex;
          return (
            <div
              key={step.id}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "center",
                gap: 10,
                padding: "8px 10px 8px 11px",
                borderRadius: 10,
                opacity: step.done ? 0.62 : isNext ? 1 : 0.42,
              }}
            >
              {/* Selection is painted by fading a layer in, not by
                  animating a colour: theme-adaptive neutrals are
                  color-mix() values, which no engine can interpolate. */}
              {isNext && (
                <motion.span
                  aria-hidden
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  transition={{
                    duration: still ? 0.2 : 0.32,
                    delay: still ? 0 : focusDelay,
                    ease: "easeOut",
                  }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 10,
                    background: `color-mix(in srgb, ${accent} 11%, transparent)`,
                    pointerEvents: "none",
                  }}
                />
              )}
              {isNext && (
                <motion.span
                  aria-hidden
                  initial={{ scaleY: still ? 1 : 0, opacity: still ? 1 : 0 }}
                  animate={{ scaleY: 1, opacity: 1 }}
                  transition={{
                    duration: still ? 0.2 : 0.3,
                    delay: still ? 0 : focusDelay,
                    ease: [0.22, 1, 0.36, 1],
                  }}
                  style={{
                    position: "absolute",
                    left: 0,
                    top: 5,
                    bottom: 5,
                    width: 2.5,
                    borderRadius: 2,
                    background: accent,
                    transformOrigin: "top center",
                  }}
                />
              )}

              <span
                aria-hidden
                style={{
                  position: "relative",
                  flex: "none",
                  display: "grid",
                  placeItems: "center",
                  width: 17,
                  height: 17,
                  borderRadius: 999,
                  background: step.done ? accent : "transparent",
                  boxShadow: step.done ? "none" : `inset 0 0 0 1.5px ${tone(24)}`,
                }}
              >
                {step.done && (
                  <svg width="9" height="9" viewBox="0 0 12 12" fill="none">
                    <path
                      d="M2.6 6.2 4.9 8.6 9.4 3.6"
                      stroke="#FFFFFF"
                      strokeWidth="1.9"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                )}
              </span>

              <span
                style={{
                  position: "relative",
                  fontSize: 12,
                  fontWeight: isNext ? 640 : 560,
                  textDecoration: step.done ? "line-through" : "none",
                  textDecorationColor: tone(30),
                }}
              >
                {step.label}
              </span>

              {isNext && (
                <motion.span
                  initial={still ? { opacity: 0 } : { opacity: 0, x: -5 }}
                  animate={{ opacity: 1, x: 0 }}
                  transition={{
                    duration: still ? 0.2 : 0.3,
                    delay: still ? 0.04 : focusDelay + 0.08,
                    ease: [0.22, 1, 0.36, 1],
                  }}
                  style={{
                    position: "relative",
                    marginLeft: "auto",
                    padding: "2px 7px",
                    borderRadius: 999,
                    fontSize: 9.5,
                    fontWeight: 640,
                    letterSpacing: "0.01em",
                    color: accent,
                    background: `color-mix(in srgb, ${accent} 16%, transparent)`,
                    whiteSpace: "nowrap",
                  }}
                >
                  {resumeLabel}
                </motion.span>
              )}
            </div>
          );
        })}
      </div>

      <motion.button
        type="button"
        onClick={onResume}
        initial={still ? { opacity: 0 } : { opacity: 0, y: 8 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{
          duration: still ? 0.2 : 0.34,
          delay: still ? 0.06 : focusDelay + 0.16,
          ease: [0.22, 1, 0.36, 1],
        }}
        style={{
          width: "100%",
          marginTop: 14,
          padding: "10px 14px",
          borderRadius: 11,
          border: "none",
          background: accent,
          color: "#FFFFFF",
          fontFamily: "inherit",
          fontSize: 12.5,
          fontWeight: 650,
          cursor: "pointer",
        }}
      >
        {actionLabel}
      </motion.button>
    </motion.div>
  );
}

About this pattern

Re-entry into something half-finished, ordered so the person is shown their credit before their homework. The card settles once, the track draws to the fraction genuinely completed on the previous visit, and only after that does the unfinished step take the accent and offer somewhere to restart. The finished steps deliberately do not animate: they happened last time, and replaying them is the product taking credit twice — leaving them still is exactly what gives the next step somewhere to stand out. The track stops on its earned value with no overshoot, and the focus on the pending row is painted by fading a tint layer in rather than by animating a colour, since a theme-derived neutral is a color-mix() value no engine can interpolate.

Returning after abandoned setupUnfinished application formPartially completed courseDraft left open in a workspace

Where it shows up

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

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Re-entry after a break leading with what was already completed before asking for more.

Related patterns