All patterns

Guardrail Notice

A declined request settles in under the prompt, an amber edge draws down it, and the way forward follows.

aicalmminimalautomatic · finite · starter · ~0.6s
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.

262 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Guardrail Notice
 *
 * A declined request explaining itself: the card settles in under the
 * prompt, an amber edge draws down its left side, and the reason and the
 * way forward arrive a beat apart.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The card is mixed from the inherited text color, so it reads correctly
 * on a light page and on a dark one; only the amber is literal.
 * Works with zero props; tune via `variant`, `title`, `body`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type GuardrailNoticeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What happened, in one line. */
  title?: string;
  /** Why it happened and what to do instead. */
  body?: string;
  /** The way forward. Omit to hide the button. */
  primaryLabel?: string;
  /** The explanation link. Omit to hide it. */
  secondaryLabel?: string;
  /** Notice accent. Semantic, so it stays literal. */
  accent?: string;
  onPrimary?: () => void;
  onSecondary?: () => void;
};

type VariantConfig = {
  /** px the card travels up as it settles. */
  riseY: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How long the edge takes to draw down the card. */
  edgeDraw: number;
  /** Gap between the title, the body and the actions. */
  stagger: number;
};

// A refusal is informative, not punitive: no shake, no red, no bounce.
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.9 —
// higher than elsewhere in the library on purpose, because a notice that
// wobbles reads as an alarm, and this one is telling the reader something
// ordinary about what it can and cannot do.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.04 — no travel to speak of, for a notice that appears often.
  subtle: {
    riseY: 4,
    spring: { type: "spring", stiffness: 520, damping: 47 },
    edgeDraw: 0.24,
    stagger: 0.05,
  },
  // ζ ≈ 0.98 — a short settle. The all-purpose setting.
  default: {
    riseY: 9,
    spring: { type: "spring", stiffness: 400, damping: 39 },
    edgeDraw: 0.32,
    stagger: 0.07,
  },
  // ζ ≈ 0.92 — more travel and a slower draw, for a full-width notice.
  playful: {
    riseY: 14,
    spring: { type: "spring", stiffness: 340, damping: 34 },
    edgeDraw: 0.4,
    stagger: 0.09,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a card and a border that are correctly toned in
 *  either theme. The amber is a state colour and stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function GuardrailNotice({
  variant = "default",
  title = "I can't complete this request",
  body = "The attached export lists full card numbers. Share a redacted copy and I'll pull the totals from that instead.",
  primaryLabel = "Use a redacted copy",
  secondaryLabel = "Why?",
  accent = "#E0A23C",
  onPrimary,
  onSecondary,
}: GuardrailNoticeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: one short fade for the whole card. The reason and the
  // way forward are the content — the staging is only pacing.
  const step = (index: number) =>
    reduceMotion
      ? { duration: 0.18, ease: "easeOut" as const }
      : {
          delay: 0.1 + index * cfg.stagger,
          duration: 0.28,
          ease: "easeOut" as const,
        };

  return (
    <motion.div
      role="status"
      aria-live="polite"
      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.riseY }}
      animate={{ opacity: 1, y: 0 }}
      transition={
        reduceMotion
          ? { duration: 0.18, ease: "easeOut" }
          : {
              y: cfg.spring,
              opacity: { duration: 0.24, ease: "easeOut" },
            }
      }
      style={{
        position: "relative",
        width: 304,
        display: "flex",
        gap: 11,
        padding: "13px 14px 13px 15px",
        borderRadius: 13,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
        overflow: "hidden",
        fontSize: 13,
      }}
    >
      {/* The edge draws downward rather than fading: a line arriving with
          direction reads as the notice being placed, where a fade reads as
          an error appearing out of nowhere. */}
      <motion.span
        aria-hidden
        initial={reduceMotion ? false : { scaleY: 0 }}
        animate={{ scaleY: 1 }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : { duration: cfg.edgeDraw, delay: 0.06, ease: [0.32, 0.72, 0, 1] }
        }
        style={{
          position: "absolute",
          left: 0,
          top: 0,
          bottom: 0,
          width: 3,
          background: accent,
          transformOrigin: "top center",
        }}
      />

      {/* The chip may scale — it is a glyph, not a word. One soft settle,
          no rebound. */}
      <motion.span
        aria-hidden
        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={
          reduceMotion
            ? { duration: 0.18, ease: "easeOut" }
            : { delay: 0.1, ...cfg.spring }
        }
        style={{
          display: "grid",
          placeItems: "center",
          width: 26,
          height: 26,
          borderRadius: 8,
          background: `${accent}24`,
          flexShrink: 0,
        }}
      >
        <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
          <path
            d="M8 1.9 13 3.7v3.9c0 3-2 5.3-5 6.5-3-1.2-5-3.5-5-6.5V3.7z"
            stroke={accent}
            strokeWidth="1.4"
            strokeLinejoin="round"
          />
          <path
            d="M5.9 7.9h4.2"
            stroke={accent}
            strokeWidth="1.6"
            strokeLinecap="round"
          />
        </svg>
      </motion.span>

      <div style={{ display: "grid", gap: 6, minWidth: 0 }}>
        <motion.span
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={step(0)}
          style={{ fontSize: 13, fontWeight: 640, lineHeight: 1.35 }}
        >
          {title}
        </motion.span>
        <motion.span
          initial={{ opacity: 0 }}
          animate={{ opacity: 0.62 }}
          transition={step(1)}
          style={{ fontSize: 12, lineHeight: 1.5 }}
        >
          {body}
        </motion.span>

        {(primaryLabel || secondaryLabel) && (
          <motion.span
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={step(2)}
            style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 2 }}
          >
            {primaryLabel && (
              <button
                type="button"
                onClick={onPrimary}
                style={{
                  padding: "6px 11px",
                  borderRadius: 8,
                  background: tone(8),
                  color: "inherit",
                  border: `1px solid ${tone(14)}`,
                  font: "inherit",
                  fontSize: 12,
                  fontWeight: 550,
                  lineHeight: 1,
                  cursor: "pointer",
                }}
              >
                {primaryLabel}
              </button>
            )}
            {secondaryLabel && (
              <button
                type="button"
                onClick={onSecondary}
                style={{
                  padding: "6px 4px",
                  background: "transparent",
                  color: "inherit",
                  border: "none",
                  font: "inherit",
                  fontSize: 12,
                  fontWeight: 550,
                  lineHeight: 1,
                  opacity: 0.6,
                  textDecoration: "underline",
                  textUnderlineOffset: 3,
                  cursor: "pointer",
                }}
              >
                {secondaryLabel}
              </button>
            )}
          </motion.span>
        )}
      </div>
    </motion.div>
  );
}

About this pattern

The moment an assistant says no. Done badly it borrows the vocabulary of a crash — red, a shake, a card slamming into place — and the reader reads punishment where the product meant policy. Here the notice settles up a few pixels with a high-damping spring, an amber edge draws down its left side to mark it as a state rather than a failure, and the reason and the way forward arrive a beat apart so the sentence is read before the button is seen. Amber, not red: nothing broke, the request simply cannot be answered as asked.

Declined requestSafety or policy noticeBlocked attachmentContent restriction message

Where it shows up

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

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A declined request answered in-thread with the reason and a suggested way to proceed.

Related patterns