All patterns

Banner Dismiss

An announcement strip fades its message, then collapses its own height so the page closes the gap.

feedbackminimalcalminteraction · finite · intermediate · ~0.4s
Interactive · click to play
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.

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

Product announcement stripTrial expiry noticePolicy or consent noticeRelease note bar

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

    Dismissible notices collapse out of the flow rather than blinking away.

Related patterns