Banner Dismiss
An announcement strip fades its message, then collapses its own height so the page closes the gap.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Banner Dismiss
*
* An announcement banner that leaves in two beats: the message fades
* and lifts, then the strip collapses its own height so whatever sits
* below rises into the space instead of snapping upward.
*
* 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 BannerDismissProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline line. */
title?: string;
/** Supporting line. Pass an empty string for a single-line banner. */
body?: string;
/** Optional inline action. Pass an empty string to hide it. */
actionLabel?: string;
/** Space kept below the banner — it collapses with the strip. */
gap?: number;
/** Fires once the strip has finished collapsing. */
onDismiss?: () => void;
};
type VariantConfig = {
/** How long the message takes to fade out. */
fade: number;
/** How long the height takes to close afterwards. */
collapse: number;
/** How far the message lifts as it goes. */
lift: number;
};
// No spring anywhere: a collapsing height that overshoots would drag the
// content below past its resting line and back, which reads as a glitch.
// Variants change how briskly the two beats run, not their order.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost no overlap between the beats — for banners that close often.
subtle: { fade: 0.08, collapse: 0.17, lift: 2 },
default: { fade: 0.14, collapse: 0.26, lift: 4 },
// A longer lift, so the message visibly leaves before the gap closes.
playful: { fade: 0.17, collapse: 0.35, lift: 10 },
};
const ACCENT = "#7C7CF0";
/** 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)`;
export default function BannerDismiss({
variant = "default",
title = "New: scheduled exports",
body = "Send any saved view to your inbox on a weekly cadence.",
actionLabel = "See what changed",
gap = 14,
onDismiss,
}: BannerDismissProps) {
const [open, setOpen] = useState(true);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
return (
<AnimatePresence initial={false} onExitComplete={onDismiss}>
{open && (
// The strip owns the gap below it, so closing the height closes
// the spacing too — otherwise the page keeps a hole where the
// banner used to be.
<motion.div
key="banner"
exit={{
height: 0,
transition: reduceMotion
? { duration: 0.12, ease: "easeOut" }
: {
duration: cfg.collapse,
ease: "easeInOut",
// Held back until the message has gone: collapsing a
// strip while its text is still legible squashes the text.
delay: cfg.fade * 0.7,
},
}}
style={{ overflow: "hidden" }}
>
<motion.div
exit={{
opacity: 0,
y: reduceMotion ? 0 : -cfg.lift,
transition: reduceMotion
? { duration: 0.1, ease: "easeOut" }
: { duration: cfg.fade, ease: "easeIn" },
}}
style={{ paddingBottom: gap }}
>
<div
role="status"
style={{
display: "flex",
alignItems: "flex-start",
gap: 11,
padding: "12px 12px 12px 13px",
borderRadius: 12,
// Accent tint over a neutral border derived from the
// inherited text color, so the strip reads correctly on a
// light page and on a dark one.
background: `color-mix(in srgb, ${ACCENT} 10%, transparent)`,
border: `1px solid ${tone(12)}`,
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
marginTop: 1,
width: 20,
height: 20,
borderRadius: "50%",
background: `color-mix(in srgb, ${ACCENT} 22%, transparent)`,
display: "grid",
placeItems: "center",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path
d="M8 3.4v5.2M8 11.6v.6"
stroke={ACCENT}
strokeWidth="1.9"
strokeLinecap="round"
/>
</svg>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.35 }}>
{title}
</div>
{body ? (
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
opacity: 0.6,
marginTop: 2,
}}
>
{body}
</div>
) : null}
{actionLabel ? (
// Taking the action closes the strip too: a banner the
// user has acted on has nothing left to say. Navigate
// first in your own handler, then let this run.
<button
type="button"
onClick={() => setOpen(false)}
style={{
marginTop: 8,
padding: 0,
fontSize: 12.5,
fontWeight: 600,
fontFamily: "inherit",
color: ACCENT,
background: "none",
border: 0,
cursor: "pointer",
}}
>
{actionLabel}
</button>
) : null}
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Dismiss announcement"
style={{
flexShrink: 0,
width: 22,
height: 22,
padding: 0,
display: "grid",
placeItems: "center",
background: "none",
border: 0,
borderRadius: 6,
color: "inherit",
opacity: 0.45,
cursor: "pointer",
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 4 12 12M12 4 4 12"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}About this pattern
Dismissing a banner is a layout event as much as a visual one: something goes away and everything under it has to move. The exit runs in two beats — the message fades and lifts a few pixels, then the strip closes its height on a short ease so the content below rises into the space rather than snapping upward. The order matters, because collapsing a strip while its sentence is still legible squashes the sentence. The spacing under the banner lives inside the collapsing element, so the gap closes with it and the page is not left with a hole. Nothing springs: an overshooting collapse would drag the page past its resting line and back.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Dismissible notices collapse out of the flow rather than blinking away.
Related patterns
- Warning Attention PullA warning tile earns a glance by drawing its own outline once, with a faint tint settling underneath.
- Error Retry NudgeA failed action answers with one short damped nudge and becomes its own retry.
- Inline Error RevealA field marks itself invalid: the error ring fades on and the message expands into place below.