Daily Goal Met
The day's tile turns over from its running total to its completed face.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Daily Goal Met
*
* The day's tile turns over. One face carries the running total, the
* other carries the result, and the tile rotates between them — the
* same object showing a different side rather than one card being
* swapped out for another.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Both faces are mixed from the inherited text color; the met colour is
* semantic and stays literal.
* Works with zero props; tune via `variant`, `value`, `goal`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DailyGoalMetProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What the tile is tracking. */
label?: string;
/** Where the day stood before the last entry. */
value?: number;
/** The day's target. */
goal?: number;
/** Unit shown after the figures. */
unit?: string;
/** Line on the completed face. */
metLabel?: string;
/** Completion colour. Semantic, so it stays literal. */
accent?: string;
/** Tile size in px. */
size?: number;
/** Fires once the tile has finished turning. */
onFlipped?: () => void;
};
type VariantConfig = {
/** Beat before the turn, so the near-miss face is read first. */
delay: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** How far the tile lifts toward the viewer mid-turn, in px. */
lift: number;
};
// Damped at 0.9 and up. A card that wobbles at the end of a flip loses
// the illusion of being a rigid object, which is the only reason to
// flip it rather than crossfade it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
subtle: {
delay: 0.24,
spring: { type: "spring", stiffness: 340, damping: 34 },
lift: 0,
},
default: {
delay: 0.38,
spring: { type: "spring", stiffness: 240, damping: 28 },
lift: 26,
},
playful: {
delay: 0.5,
spring: { type: "spring", stiffness: 180, damping: 25 },
lift: 44,
},
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function DailyGoalMet({
variant = "default",
label = "Focus today",
value = 500,
goal = 500,
unit = "min",
metLabel = "Goal met",
accent = "#2E9E6B",
size = 168,
onFlipped,
}: DailyGoalMetProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
// The flip is scheduled, so it lives in state — but the *reset* that a
// new run needs is a render-time comparison, not an effect. Keeping the
// run key in state means switching variant (or reduced motion) starts
// from the unflipped face within the same render pass, and the effect
// is left doing the one thing an effect should: owning the timer.
const runKey = `${still}:${cfg.delay}`;
const [run, setRun] = useState({ key: runKey, met: still });
if (run.key !== runKey) setRun({ key: runKey, met: still });
const met = run.key === runKey ? run.met : still;
useEffect(() => {
if (still) return;
const timer = setTimeout(
() => setRun({ key: runKey, met: true }),
cfg.delay * 1000
);
return () => clearTimeout(timer);
}, [still, cfg.delay, runKey]);
const near = Math.max(0, Math.min(value, goal));
const faceBase = {
position: "absolute" as const,
inset: 0,
display: "flex",
flexDirection: "column" as const,
justifyContent: "space-between",
padding: 16,
borderRadius: 18,
// Each face is opaque enough to hide the other mid-turn without
// ever being a hardcoded surface colour.
backfaceVisibility: "hidden" as const,
WebkitBackfaceVisibility: "hidden" as const,
};
// Reduced motion: no rotation at all. The completed face simply is the
// face, arriving on a short fade — the result survives, the trick does not.
if (still) {
return (
<motion.div
role="status"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onAnimationComplete={onFlipped}
style={{ position: "relative", width: size, height: size }}
>
<div
style={{
...faceBase,
backfaceVisibility: "visible",
WebkitBackfaceVisibility: "visible",
background: `color-mix(in srgb, ${accent} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 30%, transparent)`,
}}
>
<MetFace
accent={accent}
label={label}
metLabel={metLabel}
goal={goal}
unit={unit}
still
/>
</div>
</motion.div>
);
}
return (
<div style={{ perspective: 900, width: size, height: size }}>
<motion.div
role="status"
initial={{ rotateY: 0, z: 0 }}
animate={{ rotateY: met ? 180 : 0, z: met ? [0, cfg.lift, 0] : 0 }}
transition={{
rotateY: cfg.spring,
// The lift is a plain tween so it can peak halfway and return;
// it gives the turn a pivot instead of a hinge.
z: { duration: 0.62, times: [0, 0.45, 1], ease: "easeInOut" },
}}
onAnimationComplete={onFlipped}
style={{
position: "relative",
width: "100%",
height: "100%",
transformStyle: "preserve-3d",
}}
>
<div
style={{
...faceBase,
background: tone(6),
border: `1px solid ${tone(12)}`,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: tone(56) }}>
{label}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span
style={{
fontSize: 27,
fontWeight: 660,
letterSpacing: "-0.02em",
fontVariantNumeric: "tabular-nums",
lineHeight: 1,
}}
>
{near}
<span
style={{ fontSize: 12.5, fontWeight: 550, color: tone(50) }}
>
{` / ${goal} ${unit}`}
</span>
</span>
<span
style={{
height: 5,
borderRadius: 999,
background: tone(12),
overflow: "hidden",
}}
>
<span
style={{
display: "block",
width: `${(near / Math.max(1, goal)) * 100}%`,
height: "100%",
borderRadius: 999,
background: tone(38),
}}
/>
</span>
</span>
</div>
<div
style={{
...faceBase,
transform: "rotateY(180deg)",
background: `color-mix(in srgb, ${accent} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${accent} 30%, transparent)`,
}}
>
<MetFace
accent={accent}
label={label}
metLabel={metLabel}
goal={goal}
unit={unit}
still={false}
/>
</div>
</motion.div>
</div>
);
}
function MetFace({
accent,
label,
metLabel,
goal,
unit,
still,
}: {
accent: string;
label: string;
metLabel: string;
goal: number;
unit: string;
still: boolean;
}) {
return (
<>
<span style={{ fontSize: 12, fontWeight: 600, color: tone(56) }}>
{label}
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 9 }}>
<span style={{ color: accent, lineHeight: 0 }}>
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" aria-hidden>
<circle cx="15" cy="15" r="13.2" stroke="currentColor" strokeWidth="1.6" />
{/* The check draws rather than appearing, so the completed
face has one small event of its own on arrival. */}
<motion.path
d="M9.4 15.4 13.3 19.2 20.8 11.2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: still ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: still ? 0 : 0.3,
delay: still ? 0 : 0.26,
ease: "easeOut",
}}
/>
</svg>
</span>
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 17, fontWeight: 650, color: accent }}>
{metLabel}
</span>
<span
style={{
fontSize: 12.5,
color: tone(56),
fontVariantNumeric: "tabular-nums",
}}
>
{`${goal} of ${goal} ${unit}`}
</span>
</span>
</span>
</>
);
}About this pattern
A target being reached, told as one object showing a different side rather than one card being replaced by another. The tile rotates on its vertical axis while lifting slightly toward the viewer at the halfway point, which gives the turn a pivot instead of a hinge, and the check on the completed face strokes itself in as the face arrives. The rotation spring is damped at nine tenths: a card that wobbles as it lands stops reading as rigid, and rigidity is the only reason to flip a tile instead of crossfading it. Under reduced motion the rotation is dropped entirely and the completed face simply is the face — the result survives, the trick does not.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
Day tiles resolving to a completed state within a week strip.
Related patterns
- Streak AdvanceThe connector reaches today's cell, the cell fills, and the streak count rolls over.
- Team GoalEach person's contribution grows into one shared track in turn until the stack passes the target mark.
- Points EarnA credit chip lifts off the activity that earned it and the balance absorbs it on arrival.