All patterns

AI Result Reveal

The result card rises into place while its confidence value counts up to the final number.

aipremiumcalmautomatic · finite · intermediate · ~1.2s
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.

261 lines · react + motion only
import { useEffect } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type Variants,
} from "motion/react";

/**
 * Vibary · AI Result Reveal
 *
 * The payoff frame after an AI task: the result card rises into place
 * and its confidence value counts up to the final number.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `confidence`, `title`,
 * `summary`, `details`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AiResultRevealProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** 0–100. The value the counter climbs to. */
  confidence?: number;
  /** Small label above the result. */
  eyebrow?: string;
  title?: string;
  summary?: string;
  /** Label/value rows under the score. */
  details?: { label: string; value: string }[];
  /** Accent for the eyebrow and the meter. */
  color?: string;
};

type VariantConfig = {
  /** px the card travels up as it lands. */
  riseY: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Beat before the contents start arriving. */
  contentDelay: number;
  stagger: number;
  countSeconds: number;
};

// Damping ratios (ζ = damping / 2√stiffness) all sit at or above 0.8, so
// the card lands with at most one soft settle. A result surface that
// wobbles quietly undercuts the confidence number printed on it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.07 — no overshoot at all, for results that appear often.
  subtle: {
    riseY: 8,
    spring: { type: "spring", stiffness: 420, damping: 44 },
    contentDelay: 0.06,
    stagger: 0.05,
    countSeconds: 0.7,
  },
  // ζ ≈ 0.97 — lands clean. The all-purpose setting.
  default: {
    riseY: 14,
    spring: { type: "spring", stiffness: 380, damping: 38 },
    contentDelay: 0.09,
    stagger: 0.06,
    countSeconds: 0.9,
  },
  // ζ ≈ 0.81 — one soft settle and more travel, for a hero result.
  playful: {
    riseY: 20,
    spring: { type: "spring", stiffness: 340, damping: 30 },
    contentDelay: 0.11,
    stagger: 0.07,
    countSeconds: 0.8,
  },
};

const SAMPLE_DETAILS = [
  { label: "Priority", value: "High" },
  { label: "Owner", value: "Payments" },
];

export default function AiResultReveal({
  variant = "default",
  confidence = 94,
  eyebrow = "Analysis complete",
  title = "Routed to Billing",
  summary = "Duplicate charge on a subscription renewal, refund requested.",
  details = SAMPLE_DETAILS,
  color = "#7C7CF0",
}: AiResultRevealProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const target = Math.min(100, Math.max(0, confidence));

  // One motion value feeds both the digits and the meter, so the two can
  // never disagree mid-count.
  const count = useMotionValue(0);
  const digits = useTransform(count, (value) => String(Math.round(value)));
  const meterScale = useTransform(count, (value) => value / 100);

  useEffect(() => {
    if (reduceMotion) {
      count.set(target);
      return;
    }
    count.set(0);
    const controls = animate(count, target, {
      duration: cfg.countSeconds,
      delay: cfg.contentDelay + 0.12,
      // Hard deceleration: the last few digits are the ones people read,
      // so they must land slowly enough to be legible.
      ease: [0.22, 1, 0.36, 1],
    });
    return () => controls.stop();
  }, [count, target, reduceMotion, cfg.countSeconds, cfg.contentDelay]);

  // Reduced motion: everything arrives together on a short fade. The
  // score is still the score — only the rise and the count are dropped.
  const cardVariants: Variants = reduceMotion
    ? {
        hidden: { opacity: 0 },
        shown: { opacity: 1, transition: { duration: 0.2, ease: "easeOut" } },
      }
    : {
        hidden: { opacity: 0, y: cfg.riseY },
        shown: {
          opacity: 1,
          y: 0,
          transition: {
            y: cfg.spring,
            // Opacity runs on its own quick curve; springing it looks muddy.
            opacity: { duration: 0.22, ease: "easeOut" },
            delayChildren: cfg.contentDelay,
            staggerChildren: cfg.stagger,
          },
        },
      };

  const rowVariants: Variants = reduceMotion
    ? { hidden: { opacity: 1 }, shown: { opacity: 1 } }
    : {
        hidden: { opacity: 0, y: 6 },
        shown: {
          opacity: 1,
          y: 0,
          transition: { duration: 0.26, ease: "easeOut" },
        },
      };

  return (
    <motion.div
      variants={cardVariants}
      initial="hidden"
      animate="shown"
      style={{
        width: 288,
        padding: 16,
        borderRadius: 18,
        display: "flex",
        flexDirection: "column",
        gap: 12,
        background: "rgba(127,127,140,0.10)",
        border: "1px solid rgba(127,127,140,0.20)",
        boxShadow: "0 12px 32px rgba(0,0,0,0.12)",
      }}
    >
      <motion.div variants={rowVariants} style={{ display: "grid", gap: 4 }}>
        <span
          style={{
            fontSize: 11,
            fontWeight: 600,
            letterSpacing: "0.07em",
            textTransform: "uppercase",
            color,
          }}
        >
          {eyebrow}
        </span>
        <span style={{ fontSize: 15.5, fontWeight: 650, lineHeight: 1.3 }}>
          {title}
        </span>
        <span style={{ fontSize: 12.5, opacity: 0.6, lineHeight: 1.5 }}>
          {summary}
        </span>
      </motion.div>

      <motion.div variants={rowVariants} style={{ display: "grid", gap: 8 }}>
        <div
          style={{
            display: "flex",
            alignItems: "baseline",
            justifyContent: "space-between",
          }}
        >
          <span style={{ display: "flex", alignItems: "baseline" }}>
            {/* Tabular figures and a reserved width: the digits change
                every frame, and the % sign must not jitter with them. */}
            <motion.span
              style={{
                fontSize: 30,
                fontWeight: 650,
                lineHeight: 1,
                fontVariantNumeric: "tabular-nums",
                minWidth: "2ch",
                textAlign: "right",
              }}
            >
              {digits}
            </motion.span>
            <span style={{ fontSize: 16, fontWeight: 600, opacity: 0.7 }}>
              %
            </span>
          </span>
          <span style={{ fontSize: 11.5, opacity: 0.5 }}>confidence</span>
        </div>

        <div
          style={{
            height: 4,
            borderRadius: 999,
            background: "rgba(127,127,140,0.22)",
            overflow: "hidden",
          }}
        >
          <motion.div
            style={{
              height: "100%",
              borderRadius: 999,
              background: color,
              // scaleX, not width: transforms don't touch layout.
              transformOrigin: "left center",
              scaleX: meterScale,
            }}
          />
        </div>
      </motion.div>

      {details.length > 0 && (
        <motion.div
          variants={rowVariants}
          style={{
            display: "flex",
            gap: 8,
            paddingTop: 12,
            borderTop: "1px solid rgba(127,127,140,0.18)",
          }}
        >
          {details.map((detail) => (
            <div key={detail.label} style={{ flex: 1, display: "grid", gap: 2 }}>
              <span style={{ fontSize: 11, opacity: 0.5 }}>{detail.label}</span>
              <span style={{ fontSize: 13, fontWeight: 600 }}>
                {detail.value}
              </span>
            </div>
          ))}
        </motion.div>
      )}
    </motion.div>
  );
}

About this pattern

The payoff frame after an AI task: the ticket is classified, the document parsed, the risk scored. The card lifts in on a near-critically-damped spring and the score climbs from zero, with the meter driven by the same value so the two can never disagree. The count is not decoration — watching the number arrive says it was computed rather than fetched, and the hard deceleration keeps the last digits readable.

Classification resultConfidence scoreRisk assessmentExtracted data

Where it shows up

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

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    Fraud evaluation presented as a numeric risk score beside a meter.

Related patterns