All patterns

Streak Advance

The connector reaches today's cell, the cell fills, and the streak count rolls over.

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

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

/**
 * Vibary · Streak Counter Advance
 *
 * The day the streak grows by one. The connector reaches today's cell,
 * the cell fills, and only then does the count roll over — so the
 * number changes because something happened, not on a timer.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The strip is mixed from the inherited text color; the streak accent
 * is semantic and stays literal.
 * Works with zero props; tune via `variant`, `from`, `to`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type StreakCounterAdvanceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Streak length before today. */
  from?: number;
  /** Streak length after today. Digits that differ are the ones that roll. */
  to?: number;
  /** Single-letter day initials, left to right. */
  days?: string[];
  /** Index of today in `days` — the cell that fills. */
  todayIndex?: number;
  /** Streak color. Semantic, so it stays literal. */
  accent?: string;
  /** Line under the count. */
  caption?: string;
  /** Fires once the count has finished rolling. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** How long the connector takes to reach today. */
  reach: number;
  /** When the cell fills, relative to the start. */
  fillAt: number;
  /** When the count rolls over. */
  rollAt: number;
  /** Vertical travel of a rolling digit, as a fraction of its slot. */
  rollTravel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For a streak shown in a header, glanced at rather than watched.
  subtle: {
    reach: 0.26,
    fillAt: 180,
    rollAt: 320,
    rollTravel: 0.55,
    spring: { type: "spring", stiffness: 520, damping: 44 },
  },
  // The all-purpose setting.
  default: {
    reach: 0.34,
    fillAt: 240,
    rollAt: 430,
    rollTravel: 0.8,
    spring: { type: "spring", stiffness: 420, damping: 36 },
  },
  // A longer reach and a taller roll for a dedicated streak screen.
  playful: {
    reach: 0.44,
    fillAt: 320,
    rollAt: 560,
    rollTravel: 1,
    spring: { type: "spring", stiffness: 360, damping: 31 },
  },
};

const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const FLAME =
  "M8 1.7c2.7 2.9 4.3 4.7 4.3 7.5a4.3 4.3 0 1 1-8.6 0c0-1.7.8-3.1 1.9-4.3.2 1.1.7 1.7 1.4 2 .3-2 .6-3.7 1-5.2Z";

const DIGIT_SLOT = 34;

/**
 * A single digit column. Only the digits that actually differ between
 * `from` and `to` re-key, so 12 → 13 rolls the 2 and leaves the 1 alone.
 * The glyph translates and crossfades — never scales. A number that
 * grows while it counts stops reading as a quantity.
 */
function Digit({
  char,
  travel,
  duration,
}: {
  char: string;
  travel: number;
  duration: number;
}) {
  return (
    <span
      style={{
        position: "relative",
        display: "inline-block",
        width: "1ch",
        height: DIGIT_SLOT,
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.span
          key={char}
          initial={{ y: travel, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: -travel, opacity: 0 }}
          transition={{ duration, ease: [0.22, 1, 0.36, 1] }}
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export default function StreakCounterAdvance({
  variant = "default",
  from = 12,
  to = 13,
  days = ["M", "T", "W", "T", "F", "S", "S"],
  todayIndex = 5,
  accent = "#E4813A",
  caption = "day streak",
  onComplete,
}: StreakCounterAdvanceProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const today = Math.min(Math.max(todayIndex, 0), days.length - 1);

  // Reduced motion: the streak is simply at its new length. The roll was
  // only ever the presentation of a fact. That, and the reset a new run
  // needs, are render-time facts — holding the run key in state settles
  // both in the same pass and leaves the effect owning only its timers.
  const still = !!reduceMotion;
  const runKey = `${still}:${cfg.fillAt}:${cfg.rollAt}`;
  const fresh = { key: runKey, filled: still, advanced: still };
  const [run, setRun] = useState(fresh);
  if (run.key !== runKey) setRun(fresh);
  const filled = run.key === runKey ? run.filled : still;
  const advanced = run.key === runKey ? run.advanced : still;

  useEffect(() => {
    if (still) return;
    const fill = setTimeout(
      () => setRun((prev) => ({ ...prev, key: runKey, filled: true })),
      cfg.fillAt
    );
    const roll = setTimeout(
      () => setRun((prev) => ({ ...prev, key: runKey, advanced: true })),
      cfg.rollAt
    );
    return () => {
      clearTimeout(fill);
      clearTimeout(roll);
    };
  }, [still, cfg.fillAt, cfg.rollAt, runKey]);

  const value = String(advanced ? to : from);
  const travel = DIGIT_SLOT * cfg.rollTravel;
  const rollDuration = reduceMotion ? 0 : 0.36;

  return (
    <div
      style={{ display: "flex", flexDirection: "column", gap: 16, width: 244 }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: filled ? 1 : 0.4 }}
          transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
          style={{ color: accent, lineHeight: 0 }}
        >
          <svg width="20" height="20" viewBox="0 0 16 16" fill="none">
            <path d={FLAME} fill="currentColor" />
          </svg>
        </motion.span>

        <span
          aria-label={`${to} ${caption}`}
          style={{
            display: "inline-flex",
            fontSize: 27,
            fontWeight: 660,
            letterSpacing: "-0.02em",
            fontVariantNumeric: "tabular-nums",
            lineHeight: 1,
          }}
        >
          {value.split("").map((char, index) => (
            <Digit
              key={index}
              char={char}
              travel={travel}
              duration={rollDuration}
            />
          ))}
        </span>

        <span
          style={{
            fontSize: 13,
            color: tone(58),
            alignSelf: "flex-end",
            paddingBottom: 6,
          }}
        >
          {caption}
        </span>
      </div>

      <div style={{ display: "flex", alignItems: "flex-start", gap: 0 }}>
        {days.map((day, index) => {
          const done = index < today;
          const isToday = index === today;
          const on = done || (isToday && filled);
          return (
            <div
              key={index}
              style={{
                position: "relative",
                flex: 1,
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                gap: 7,
              }}
            >
              {index > 0 && (
                <span
                  aria-hidden
                  style={{
                    position: "absolute",
                    top: 10,
                    right: "50%",
                    width: "100%",
                    height: 2,
                    borderRadius: 2,
                    background: tone(12),
                  }}
                >
                  {/* The connector reaching today is what makes the fill
                      feel caused rather than announced. */}
                  <motion.span
                    style={{
                      display: "block",
                      height: "100%",
                      borderRadius: 2,
                      background: accent,
                      transformOrigin: "left center",
                    }}
                    initial={{ scaleX: done ? 1 : 0 }}
                    animate={{ scaleX: done || isToday ? 1 : 0 }}
                    transition={{
                      duration: isToday && !reduceMotion ? cfg.reach : 0,
                      ease: [0.4, 0, 0.2, 1],
                    }}
                  />
                </span>
              )}

              <span
                style={{
                  position: "relative",
                  width: 20,
                  height: 20,
                  borderRadius: 999,
                  border: `1.5px solid ${on ? "transparent" : tone(16)}`,
                  display: "block",
                }}
              >
                <motion.span
                  style={{
                    position: "absolute",
                    inset: -1.5,
                    borderRadius: 999,
                    background: accent,
                  }}
                  initial={{ scale: done ? 1 : 0.4, opacity: done ? 1 : 0 }}
                  animate={{ scale: on ? 1 : 0.4, opacity: on ? 1 : 0 }}
                  transition={
                    isToday && !reduceMotion
                      ? cfg.spring
                      : { duration: 0, ease: "linear" }
                  }
                  onAnimationComplete={isToday ? onComplete : undefined}
                />
              </span>

              <span
                style={{
                  fontSize: 10.5,
                  fontWeight: isToday ? 640 : 500,
                  color: isToday ? "inherit" : tone(45),
                }}
              >
                {day}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

About this pattern

A streak is a chain, so the motion builds it as one: the segment between yesterday and today extends, today's marker fills on an over-damped spring, and only after that does the number roll from twelve to thirteen. Ordering it this way makes the count a consequence rather than an announcement. Only the digits that actually change are re-keyed, so twelve to thirteen rolls the trailing digit and leaves the leading one perfectly still — the trick that separates a real odometer from a whole number flipping over. Digits translate and crossfade inside a fixed slot and never scale, because a figure that swells while it counts stops reading as a quantity.

Daily streakHabit trackerLearning app check-inWorkout logged

Where it shows up

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

  • 10:15
    Today
    412 kcalMove8,240Steps9 hrsStand
    HomeSearchActivityProfile
    Activity summary

    Week strip with today's marker filling, then the streak count advancing.

Related patterns