AI Result Reveal
The result card rises into place while its confidence value counts up to the final number.
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 } 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Fraud evaluation presented as a numeric risk score beside a meter.
Related patterns
- Streaming Text RevealWords arrive one at a time behind a soft caret — the answer is being written, not loaded.
- Translate CrossfadeCopy hands over to its translation while the block eases to the new text's height.
- Source Fan OutA tilted deck of reference cards fans apart to show what an answer was grounded in.