Warning Attention Pull
A warning tile earns a glance by drawing its own outline once, with a faint tint settling underneath.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Warning Attention Pull
*
* A warning tile that earns a glance by drawing its own outline once,
* left to right around the box, while a faint tint settles underneath.
* No flash, no pulse, no second pass.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `title`, `body`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type WarningAttentionPullProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline line. */
title?: string;
/** Supporting line. */
body?: string;
/** Tile width in px — the outline is drawn to match it exactly. */
width?: number;
/** Tile height in px. */
height?: number;
/** Accent used for the outline, the mark and the tint. */
color?: string;
/** Fires once the outline has closed. */
onComplete?: () => void;
};
type VariantConfig = {
/** How long the outline takes to travel all the way round. */
drawDuration: number;
/** How long the tint takes to settle in behind it. */
tintDuration: number;
/** How far the contents rise into place. */
lift: number;
};
// Nothing here springs and nothing repeats. A warning that pulses trains
// people to look away from it; this one asks once. Variants change how
// deliberate the single pass is.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and quiet — for a warning that shares the page with others.
subtle: { drawDuration: 0.55, tintDuration: 0.4, lift: 2 },
default: { drawDuration: 0.85, tintDuration: 0.5, lift: 4 },
// A slow, deliberate lap: the eye follows the line the whole way.
playful: { drawDuration: 1.15, tintDuration: 0.6, lift: 7 },
};
const WARNING = "#D97706";
const RADIUS = 13;
const STROKE = 1.6;
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/**
* The outline as an explicit path rather than a `rect`: `pathLength` is
* only dependable on `path` across browsers, and a rounded rect is nine
* commands. Starts at the top-left corner and runs clockwise.
*/
function outlinePath(width: number, height: number, radius: number, inset: number) {
const x0 = inset;
const y0 = inset;
const x1 = width - inset;
const y1 = height - inset;
return [
`M ${x0 + radius} ${y0}`,
`H ${x1 - radius}`,
`A ${radius} ${radius} 0 0 1 ${x1} ${y0 + radius}`,
`V ${y1 - radius}`,
`A ${radius} ${radius} 0 0 1 ${x1 - radius} ${y1}`,
`H ${x0 + radius}`,
`A ${radius} ${radius} 0 0 1 ${x0} ${y1 - radius}`,
`V ${y0 + radius}`,
`A ${radius} ${radius} 0 0 1 ${x0 + radius} ${y0}`,
"Z",
].join(" ");
}
export default function WarningAttentionPull({
variant = "default",
title = "Usage is close to your plan limit",
body = "92% of this month's included API calls have been used.",
width = 336,
height = 96,
color = WARNING,
onComplete,
}: WarningAttentionPullProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const path = outlinePath(width, height, RADIUS, STROKE / 2);
return (
<div
role="status"
style={{
position: "relative",
width,
height,
borderRadius: RADIUS,
// Neutral surface underneath, so the tile sits correctly on a
// light page and on a dark one; only the warning tint is literal.
background: tone(5),
boxSizing: "border-box",
}}
>
{/* The tint arrives before the line closes, so the tile is already
reading as a warning by the time the outline lands. */}
<motion.span
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0.2 : cfg.tintDuration,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
borderRadius: RADIUS,
background: `color-mix(in srgb, ${color} 9%, transparent)`,
pointerEvents: "none",
}}
/>
<svg
aria-hidden
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
fill="none"
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
{/* A resting outline underneath means the tile is never
border-less, even on the first frame of the draw. */}
<path d={path} stroke={tone(12)} strokeWidth={STROKE} />
<motion.path
d={path}
stroke={color}
strokeWidth={STROKE}
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.25, ease: "easeOut" }
: { duration: cfg.drawDuration, ease: "easeInOut" }
}
onAnimationComplete={onComplete}
/>
</svg>
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.lift }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.2 : 0.32,
ease: "easeOut",
delay: reduceMotion ? 0 : 0.08,
}}
style={{
position: "relative",
height: "100%",
display: "flex",
alignItems: "center",
gap: 12,
padding: "0 16px",
boxSizing: "border-box",
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: 26,
height: 26,
borderRadius: 8,
background: `color-mix(in srgb, ${color} 18%, transparent)`,
display: "grid",
placeItems: "center",
}}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M8 2.4 15 13.6H1z"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M8 6.6v3.1M8 11.6h.01"
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.35 }}>
{title}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
opacity: 0.6,
marginTop: 2,
}}
>
{body}
</div>
</div>
</motion.div>
</div>
);
}About this pattern
Getting attention without spending it. The tile draws its outline in a single clockwise pass while a faint tint settles behind the contents, which is enough to move the eye across the page and no more — a warning that pulses or flashes trains people to look away from it, and one that repeats becomes furniture. A resting outline sits under the animated one so the tile is never border-less on the first frame, and the outline is an explicit path rather than a rect because stroke length is only dependable on paths across browsers. Nothing springs, nothing repeats, and the text only fades up a few pixels into place.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Account warnings sit in a bordered tile that is emphasised rather than flashed.
Related patterns
- Banner DismissAn announcement strip fades its message, then collapses its own height so the page closes the gap.
- Offline Banner DropA connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
- Password Strength BarA segmented meter fills bar by bar and shifts hue as a password improves.