All patterns

Warning Attention Pull

A warning tile earns a glance by drawing its own outline once, with a faint tint settling underneath.

feedbackcalmminimalautomatic · finite · intermediate · ~0.9s
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.

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

Usage nearing a limitPayment method expiringPermission needs reviewRetention policy notice

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

    Account warnings sit in a bordered tile that is emphasised rather than flashed.

Related patterns