All patterns

Daily Goal Met

The day's tile turns over from its running total to its completed face.

achievementfriendlyenergeticautomatic · finite · intermediate · ~1.0s
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.

305 lines · react + motion only
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.

Daily target reachedHabit tile completedCalorie or step goal metReading minutes hit

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

    Day tiles resolving to a completed state within a week strip.

Related patterns