All patterns

Profile Completion Ring

The ring around the avatar advances one arc per field, and each row marks itself as the arc reaches it.

onboardingminimalpremiumautomatic · finite · intermediate · ~2.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.

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

/**
 * Vibary · Profile Completion Ring
 *
 * A ring around the avatar advances one arc per field as the profile
 * fills in, each row marking itself as the arc reaches it. On the last
 * field the ring changes colour instead of celebrating.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `fields`, `initials`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ProfileCompletionRingProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field names, in the order they complete. */
  fields?: string[];
  /** Monogram inside the ring. */
  initials?: string;
  /** Ring color while the profile is unfinished. */
  accent?: string;
  /** Ring color once every field is in. */
  completeColor?: string;
  /** Fires each time a field completes, with the new count. */
  onAdvance?: (filled: number) => void;
};

type VariantConfig = {
  /** Pause before the first arc, in seconds. */
  startDelay: number;
  /** Time between fields completing, in seconds. */
  stepSeconds: number;
  arc: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the arc may spring, the readout may not. Every spring
// sits at or above a 0.8 damping ratio, so the ring never rocks past its
// value and back — a progress indicator that overshoots is lying about
// the number underneath it, and the percentage is plain text that never
// scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk and nearly linear. For a ring in a sidebar.
  subtle: {
    startDelay: 0.25,
    stepSeconds: 0.45,
    arc: { type: "spring", stiffness: 320, damping: 34 },
  },
  // One field per beat. The all-purpose setting.
  default: {
    startDelay: 0.35,
    stepSeconds: 0.62,
    arc: { type: "spring", stiffness: 240, damping: 30 },
  },
  // Slower arcs with more air between them, for a settings page hero.
  playful: {
    startDelay: 0.45,
    stepSeconds: 0.8,
    arc: { type: "spring", stiffness: 180, damping: 24 },
  },
};

/** 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_FIELDS = ["Profile photo", "Display name", "Role", "Time zone"];

const RING_SIZE = 76;
const RING_CENTER = RING_SIZE / 2;
const RING_RADIUS = 33;
const RING_STROKE = 4;

export default function ProfileCompletionRing({
  variant = "default",
  fields = SAMPLE_FIELDS,
  initials = "AR",
  accent = "#5B5BD6",
  completeColor = "#3E9B6B",
  onAdvance,
}: ProfileCompletionRingProps) {
  const [stepped, setStepped] = useState(0);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const total = fields.length;
  // Reduced motion: the profile is shown as it stands, all at once. The
  // number and the marks carry the same information the arcs did, so
  // this is derived rather than stepped — no timers, no cascade.
  const filled = reduceMotion ? total : stepped;
  const complete = filled >= total;
  const percent = Math.round((filled / total) * 100);

  useEffect(() => {
    if (reduceMotion || stepped >= total) return;
    const wait = stepped === 0 ? cfg.startDelay : cfg.stepSeconds;
    const timer = setTimeout(() => {
      setStepped(stepped + 1);
      onAdvance?.(stepped + 1);
    }, wait * 1000);
    return () => clearTimeout(timer);
  }, [reduceMotion, stepped, total, cfg.startDelay, cfg.stepSeconds, onAdvance]);

  return (
    <div
      style={{
        width: 320,
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        boxSizing: "border-box",
      }}
    >
      <div style={{ fontSize: 15, fontWeight: 650 }}>Complete your profile</div>
      <p style={{ margin: "4px 0 14px", fontSize: 12, opacity: 0.55 }}>
        Teammates recognise a filled-in profile faster than an initial.
      </p>

      <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
        <div style={{ flex: "none", width: RING_SIZE }}>
          <div style={{ position: "relative", height: RING_SIZE }}>
            <svg
              width={RING_SIZE}
              height={RING_SIZE}
              viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
              fill="none"
              role="img"
              aria-label={`Profile ${percent} percent complete`}
              style={{ display: "block" }}
            >
              <circle
                cx={RING_CENTER}
                cy={RING_CENTER}
                r={RING_RADIUS}
                stroke={tone(13)}
                strokeWidth={RING_STROKE}
              />
              {/* Dash offset starts at 3 o'clock, so the group is rotated
                  to put the first field at the top where it is read. */}
              <g transform={`rotate(-90 ${RING_CENTER} ${RING_CENTER})`}>
                <motion.circle
                  cx={RING_CENTER}
                  cy={RING_CENTER}
                  r={RING_RADIUS}
                  strokeWidth={RING_STROKE}
                  strokeLinecap="round"
                  initial={{ pathLength: 0, stroke: accent }}
                  animate={{
                    pathLength: filled / total,
                    stroke: complete ? completeColor : accent,
                  }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : {
                          ...cfg.arc,
                          stroke: { duration: 0.4, ease: "easeOut" as const },
                        }
                  }
                />
              </g>
            </svg>

            <span
              aria-hidden
              style={{
                position: "absolute",
                inset: RING_STROKE + 4,
                display: "grid",
                placeItems: "center",
                borderRadius: 999,
                background: tone(10),
                fontSize: 17,
                fontWeight: 650,
                letterSpacing: 0.4,
                opacity: 0.7,
              }}
            >
              {initials}
            </span>
          </div>

          <div
            style={{
              marginTop: 8,
              textAlign: "center",
              fontSize: 11.5,
              fontWeight: 600,
              // Plain text, swapped not animated: digits that scale or
              // roll pull the eye off the arc that is actually moving.
              fontVariantNumeric: "tabular-nums",
              opacity: 0.6,
            }}
          >
            {complete ? "All set" : `${percent}% done`}
          </div>
        </div>

        <div style={{ flex: 1, minWidth: 0 }}>
          {fields.map((field, index) => {
            const done = index < filled;
            return (
              <div
                key={field}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 9,
                  height: 26,
                  fontSize: 12.5,
                }}
              >
                <span
                  style={{
                    display: "grid",
                    placeItems: "center",
                    flex: "none",
                    width: 16,
                    height: 16,
                    borderRadius: 999,
                    border: `1.5px solid ${done ? "transparent" : tone(18)}`,
                    background: done ? tone(8) : "transparent",
                  }}
                >
                  <svg width="11" height="11" viewBox="0 0 16 16" fill="none">
                    <motion.path
                      d="M3.6 8.4 6.6 11.4 12.4 5.2"
                      strokeWidth="2.2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      initial={{ pathLength: 0, stroke: accent }}
                      animate={{
                        pathLength: done ? 1 : 0,
                        stroke: complete ? completeColor : accent,
                      }}
                      transition={
                        reduceMotion
                          ? { duration: 0 }
                          : {
                              duration: 0.28,
                              ease: "easeOut" as const,
                              stroke: { duration: 0.4, ease: "easeOut" as const },
                            }
                      }
                    />
                  </svg>
                </span>
                <motion.span
                  animate={{ opacity: done ? 0.85 : 0.42 }}
                  transition={{ duration: 0.3, ease: "easeOut" }}
                >
                  {field}
                </motion.span>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

About this pattern

A completeness indicator that shows its working. The arc around the avatar extends by one quarter as each field lands, and the matching row in the list draws its own mark at the same moment, so the ring and the reasons for it are never out of step. The arc springs but never overshoots — an indicator that rocks past its value and back is lying about the number printed underneath it — and that number is plain tabular text that swaps rather than counts, because scaling digits beside a moving arc splits the eye in two. Finishing changes the ring's colour and the label, and that is the whole reward; there is nothing else to celebrate about a filled-in form.

Profile completenessOnboarding flowAccount setup meterSettings nudge

Where it shows up

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

  • 10:15
    Priya RamanTrail running · Gothenburg
    214Posts1,842Followers306Following
    Follow
    Priya Raman2hFinally got the trail loop under an hour. Four months of Tuesdays.
    12814
    Profile page

    A ring beside the avatar reports how much of the profile is filled in.

Related patterns