Streak Advance
The connector reaches today's cell, the cell fills, and the streak count rolls over.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
Week strip with today's marker filling, then the streak count advancing.