All patterns

Team Goal

Each person's contribution grows into one shared track in turn until the stack passes the target mark.

achievementenergeticfriendlyautomatic · finite · intermediate · ~1.3s
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.

392 lines · react + motion only
import { useEffect, useMemo, useRef, useState } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Team Goal
 *
 * What everyone did, added up. Each person's contribution grows into the
 * same track in turn, their initials coming up to full as their share
 * lands, and the running figure counts along with the fill so the number
 * and the bar are always telling the same story.
 *
 * The target sits as a fixed mark across the track. The moment the stack
 * passes it, the mark takes the accent and a line underneath says so —
 * bound to the running value rather than to a timer, so the words can
 * never appear while the bar is still short of the mark.
 *
 * One hue throughout. Contributions separate by weight, not by colour: a
 * shared goal painted in four different colours reads as four goals.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Track and chrome are mixed from the inherited text color; the goal
 * colour is semantic and stays literal.
 * Works with zero props; tune via `variant`, `contributors`, `target`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TeamContribution = {
  id: string;
  /** Two-letter monogram shown above the track. */
  initials: string;
  /** How much this person added. */
  value: number;
};

export type TeamGoalCollectiveProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Small label above the goal name. */
  eyebrow?: string;
  /** What the team was working towards. */
  title?: string;
  /** Your own team. The embedded sample is used when omitted. */
  contributors?: TeamContribution[];
  /** The number the team was aiming at. */
  target?: number;
  /** What is being counted, e.g. "replies". */
  unit?: string;
  /** Wording once the collective total passes the target. */
  reachedLabel?: string;
  /** Goal colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires at the frame the stack passes the target. */
  onReached?: () => void;
};

type VariantConfig = {
  /** Beat before the first share starts growing. */
  delay: number;
  /** How long one person's share takes to fill. */
  fill: number;
  /** Gap between one share starting and the next. */
  stagger: number;
};

// No springs on the track at all. A collective total that overshoots is
// claiming work the team did not do, which is a reporting error wearing
// a flourish; every fill here is a decelerating tween that stops where
// the data stops. Variants change pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk. For a dashboard tile refreshed all day.
  subtle: { delay: 0.1, fill: 0.34, stagger: 0.1 },
  // The all-purpose setting: each share is separately legible.
  default: { delay: 0.18, fill: 0.5, stagger: 0.17 },
  // Slow enough to watch each person's share arrive by name.
  playful: { delay: 0.24, fill: 0.66, stagger: 0.24 },
};

/** 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)`;

/** One hue, four weights. Separation by density, never by colour. */
const WEIGHTS = [1, 0.82, 0.66, 0.5];

const SAMPLE_TEAM: TeamContribution[] = [
  { id: "ar", initials: "AR", value: 420 },
  { id: "js", initials: "JS", value: 360 },
  { id: "mk", initials: "MK", value: 290 },
  { id: "tp", initials: "TP", value: 170 },
];

const TRACK = 15;

export default function TeamGoalCollective({
  variant = "default",
  eyebrow = "Team goal",
  title = "Support replies this quarter",
  contributors = SAMPLE_TEAM,
  target = 1000,
  unit = "replies",
  reachedLabel = "Target passed",
  accent = "#3B6FD4",
  onReached,
}: TeamGoalCollectiveProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const total = contributors.reduce((sum, person) => sum + person.value, 0);
  const span = Math.max(total, target) || 1;
  const targetPct = (target / span) * 100;
  const fillWindow = cfg.fill + cfg.stagger * Math.max(0, contributors.length - 1);

  const tally = useMotionValue(still ? total : 0);
  const digits = useTransform(tally, (v) =>
    Math.round(v).toLocaleString("en-US")
  );

  // The acknowledgement is a fact about the value, so it resets during
  // render whenever the run itself changes. The effect below owns the
  // animation and nothing else.
  const runKey = `${still}:${total}:${target}:${fillWindow}:${cfg.delay}`;
  const fresh = { key: runKey, reached: still && total >= target };
  const [run, setRun] = useState(fresh);
  if (run.key !== runKey) setRun(fresh);
  const reached = run.key === runKey ? run.reached : fresh.reached;

  useMotionValueEvent(tally, "change", (v) => {
    if (!reached && v >= target) setRun({ key: runKey, reached: true });
  });

  const onReachedRef = useRef(onReached);
  useEffect(() => {
    onReachedRef.current = onReached;
  }, [onReached]);

  useEffect(() => {
    if (reached) onReachedRef.current?.();
  }, [reached]);

  useEffect(() => {
    // Reduced motion: the total is simply the total. The fill was only
    // ever the presentation of a number that is already true.
    if (still) {
      tally.set(total);
      return;
    }
    tally.set(0);
    const controls = animate(tally, total, {
      duration: fillWindow,
      delay: cfg.delay,
      // Matches the shares: quick to establish, then settling in.
      ease: [0.32, 0.72, 0.3, 1],
    });
    return () => controls.stop();
  }, [still, tally, total, fillWindow, cfg.delay]);

  // Each share's offset is derived up front rather than accumulated
  // inside the map: a running total mutated while rendering is state
  // pretending not to be, and it reads differently on a re-render.
  const shares = useMemo(
    () =>
      contributors.map((person, index) => {
        const before = contributors
          .slice(0, index)
          .reduce((sum, earlier) => sum + earlier.value, 0);
        return {
          person,
          left: (before / span) * 100,
          width: (person.value / span) * 100,
        };
      }),
    [contributors, span]
  );

  return (
    <div style={{ width: 300, display: "flex", flexDirection: "column", gap: 13 }}>
      <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
        <span
          style={{
            fontSize: 10,
            fontWeight: 620,
            letterSpacing: "0.09em",
            textTransform: "uppercase",
            color: tone(45),
          }}
        >
          {eyebrow}
        </span>
        <span style={{ fontSize: 13.5, fontWeight: 650, letterSpacing: "-0.01em" }}>
          {title}
        </span>
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
        {contributors.map((person, index) => (
          // Each monogram comes up to full as its own share lands, so the
          // bar can be read as people rather than as one anonymous total.
          <motion.span
            key={person.id}
            initial={{ opacity: still ? 1 : 0.32 }}
            animate={{ opacity: 1 }}
            transition={{
              duration: still ? 0.2 : 0.3,
              delay: still ? 0 : cfg.delay + cfg.stagger * index + cfg.fill * 0.5,
              ease: "easeOut",
            }}
            style={{
              display: "grid",
              placeItems: "center",
              width: 25,
              height: 25,
              borderRadius: 999,
              fontSize: 9.5,
              fontWeight: 680,
              letterSpacing: "0.02em",
              color: accent,
              background: `color-mix(in srgb, ${accent} 15%, transparent)`,
            }}
          >
            {person.initials}
          </motion.span>
        ))}
        <span style={{ marginLeft: "auto", fontSize: 11, color: tone(46) }}>
          {`${contributors.length} contributors`}
        </span>
      </div>

      <div style={{ position: "relative" }}>
        {/* The target reads as a pointer above the track rather than as
            a line inside it: a notch between accent shares is
            indistinguishable from the slivers that separate them. Two
            stacked pointers crossfade, since the resting neutral is a
            color-mix() value no engine can interpolate to an accent. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: reached ? 0 : 1 }}
          transition={{ duration: still ? 0.16 : 0.26, ease: "easeOut" }}
          style={{
            position: "absolute",
            left: `${targetPct}%`,
            top: -7,
            marginLeft: -4,
            width: 0,
            height: 0,
            borderLeft: "4px solid transparent",
            borderRight: "4px solid transparent",
            borderTop: `5px solid ${tone(30)}`,
          }}
        />
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: reached ? 1 : 0 }}
          transition={{ duration: still ? 0.16 : 0.26, ease: "easeOut" }}
          style={{
            position: "absolute",
            left: `${targetPct}%`,
            top: -7,
            marginLeft: -4,
            width: 0,
            height: 0,
            borderLeft: "4px solid transparent",
            borderRight: "4px solid transparent",
            borderTop: `5px solid ${accent}`,
          }}
        />
        <div
          style={{
            position: "relative",
            height: TRACK,
            borderRadius: 999,
            background: tone(8),
            overflow: "hidden",
          }}
        >
          {shares.map(({ person, left, width }, index) => {
            const last = index === shares.length - 1;
            return (
              <motion.span
                key={person.id}
                aria-hidden
                initial={{ scaleX: still ? 1 : 0 }}
                animate={{ scaleX: 1 }}
                transition={{
                  duration: still ? 0 : cfg.fill,
                  delay: still ? 0 : cfg.delay + cfg.stagger * index,
                  ease: [0.32, 0.72, 0.3, 1],
                }}
                style={{
                  position: "absolute",
                  top: 0,
                  bottom: 0,
                  left: `${left}%`,
                  // A 2px sliver of empty track separates neighbouring
                  // shares — cheaper and more honest than a second colour.
                  width: last ? `${width}%` : `calc(${width}% - 2px)`,
                  transformOrigin: "left center",
                  background: accent,
                  opacity: WEIGHTS[index % WEIGHTS.length],
                }}
              />
            );
          })}

          {/* Inside the track the mark is a knockout, which is legible
              only once accent is behind it — exactly the state it is
              meant to describe. */}
          <motion.span
            aria-hidden
            initial={false}
            animate={{ opacity: reached ? 0.85 : 0 }}
            transition={{ duration: still ? 0.16 : 0.26, ease: "easeOut" }}
            style={{
              position: "absolute",
              top: 0,
              bottom: 0,
              left: `${targetPct}%`,
              width: 2,
              marginLeft: -1,
              background: "#FFFFFF",
            }}
          />
        </div>

        <div
          style={{
            position: "relative",
            height: 15,
            marginTop: 5,
            fontSize: 10,
            color: tone(44),
          }}
        >
          <span
            style={{
              position: "absolute",
              left: `${targetPct}%`,
              transform: "translateX(-50%)",
              whiteSpace: "nowrap",
            }}
          >
            {`Target ${target.toLocaleString("en-US")}`}
          </span>
        </div>
      </div>

      <div style={{ display: "flex", alignItems: "baseline", gap: 7 }}>
        {/* Driven by a motion value: the figure changes, its box never
            does. A total that scales as it counts stops being a total. */}
        <motion.span
          style={{
            fontSize: 24,
            fontWeight: 660,
            letterSpacing: "-0.02em",
            fontVariantNumeric: "tabular-nums",
            lineHeight: 1.1,
          }}
        >
          {digits}
        </motion.span>
        <span style={{ fontSize: 11.5, color: tone(50) }}>{unit}</span>

        <motion.span
          initial={still ? { opacity: 0 } : { opacity: 0, y: 5 }}
          animate={reached ? { opacity: 1, y: 0 } : { opacity: 0, y: still ? 0 : 5 }}
          transition={{ duration: still ? 0.18 : 0.28, ease: [0.22, 1, 0.36, 1] }}
          style={{
            marginLeft: "auto",
            padding: "3px 9px",
            borderRadius: 999,
            fontSize: 10.5,
            fontWeight: 640,
            color: accent,
            background: `color-mix(in srgb, ${accent} 14%, transparent)`,
          }}
        >
          {reachedLabel}
        </motion.span>
      </div>
    </div>
  );
}

About this pattern

A goal nobody hit alone. Contributions fill the same track one after another, each person's monogram coming up to full as their share lands, so the total can be read as people rather than as one anonymous quantity. The running figure counts along with the fill on the same window and curve, which means the number and the track are never telling different stories. Three choices carry the restraint. Nothing here springs — a collective total that overshoots is claiming work the team did not do, so every share is a decelerating tween that stops where the data stops. Shares separate by weight of a single hue rather than by four colours, because a shared goal painted four ways reads as four goals. And the acknowledgement is bound to the running value rather than to a timer, so the words can never appear while the track is still short of the mark.

Quarterly team targetFundraising drive totalCommunity challengeCompany-wide volunteering hours

Where it shows up

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

  • 10:15
    This week
    1Priya Raman2,480 pts
    2You2,310 pts
    3Marcus Bell2,145 pts
    4Dana Whitfield1,980 pts
    HomeSearchActivityProfile
    Leaderboard

    A club's combined distance filling toward a shared target with members named alongside.

Related patterns