Feedback
Banner
A full-width message across a page or section, such as maintenance or a trial ending, optionally dismissible.
- Built on
ARIA live regionsposition: stickytransition-behavior: allow-discrete- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.6 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Banner, BannerAction } from "@/components/equip/banner";
export default function Demo() {
return (
<div style={{ width: 224 }}>
<Banner tone="warn">
Trial ends in 3 days. <BannerAction>Add a card</BannerAction>
</Banner>
</div>
);
}Your versionchange its options in Look, This part
Examples
Tones
Danger is the only tone that interrupts with role="alert"; the rest are polite status messages.
Code
import { Banner, BannerAction } from "@/components/equip/banner";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8 }}>
<Banner>Scheduled maintenance on Sunday 02:00 to 03:00 UTC. Builds will queue.</Banner>
<Banner tone="info">
Version 4.2 is out. <BannerAction>Read the changelog</BannerAction>
</Banner>
<Banner tone="ok">Your domain acme.dev is verified.</Banner>
<Banner tone="warn">You have used 92% of this month's build minutes.</Banner>
<Banner tone="danger">
Payment failed. Deploys pause on 14 November. <BannerAction>Update card</BannerAction>
</Banner>
</div>
);
}Sticky and dismissible
Sticky banners lay their tint over the page colour, so text scrolling underneath never shows through.
api-gateway
billing-worker
edge-cache
search-indexer
mailer
auth
cron
Code
import { Banner, BannerAction } from "@/components/equip/banner";
export default function Example() {
return (
<div style={{ height: 160, overflow: "auto", border: "1px solid var(--eq-line)", borderRadius: 8 }}>
<Banner tone="info" sticky dismissible>
You are viewing a read-only snapshot from 9 November. <BannerAction>Switch to live</BannerAction>
</Banner>
<div style={{ display: "grid", gap: 8, padding: 16, fontSize: 14 }}>
{["api-gateway", "billing-worker", "edge-cache", "search-indexer", "mailer", "auth", "cron"].map((s) => (
<div key={s}>{s}</div>
))}
</div>
</div>
);
}Centred announcement
align="centre" puts the message on the middle of the page; the close button keeps the end edge, with matching room left on both sides.
Code
import { Banner, BannerAction } from "@/components/equip/banner";
export default function Example() {
return (
<div style={{ width: "100%", display: "grid", gap: 8 }}>
<Banner tone="info" align="center">
Scheduled maintenance on Sunday 16 November, 02:00 to 03:00 UTC. <BannerAction href="#status">See the status page</BannerAction>
</Banner>
<Banner align="center" dismissible>
Postgres branching is now in every plan. <BannerAction href="#changelog">Read the changelog</BannerAction>
</Banner>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
tone | "neutral" | "info" | "ok" | "warn" | "danger" | "neutral" | Sets the tint and the icon. Danger uses role="alert", every other tone role="status". |
dismissible | boolean | false | Adds a close button. The banner collapses and stays hidden. |
onDismiss | () => void | none | Called when the close button is pressed, for remembering the choice. |
sticky | boolean | false | Sticks to the top of the scrolling container. |
icon | ReactNode | none | Replaces the tone's icon. Neutral has none unless one is given. |
align | "start" | "center" | "start" | Where the icon and text sit. center ("centre" is accepted too) suits a one-line announcement across the top of a site; the close button stays at the end edge. |
...rest | ComponentProps<"div"> | none | Every native div attribute except role, which the part owns. |
BannerAction | href? | none | An underlined inline action: a link when given href, otherwise a button. Takes every anchor or button attribute. |
Anatomy
| root | .eq-banner | Tone is on data-tone, centring on data-align; data-sticky and data-dismissed mark state. |
|---|---|---|
| clip | .eq-banner-clip | Clips the content while the row collapses. |
| body | .eq-banner-body | The tinted row. |
| icon | .eq-banner-icon | |
| text | .eq-banner-text | |
| action | .eq-banner-action | BannerAction: a link with href, otherwise a button. |
| close | .eq-banner-close |
Keyboard
| Enter | Follows the action link, or presses the action or close button. |
|---|---|
| Space | Presses the action or close button. |
Notes
- A client component only because dismissal keeps state; without dismissible it holds no state and runs no effects.
- Only danger interrupts with role="alert"; warn is a polite status like the rest, so say the urgency in the words.
- A live region is announced when its content changes, not when it is already on the page at load; mount the banner when the event happens if it must be heard.
- The tone's icon is hidden from assistive tech, so the wording must carry the tone, such as "Trial ends in 3 days".
- Dismissal is not remembered across visits; store it from onDismiss and stop rendering the banner.
- The close button disappears when pressed, so focus falls back to the page; move it somewhere sensible if the banner sat early in the tab order.
- A sticky banner lays its tint over the page background so content scrolling underneath does not show through.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/banner.tsx121 lines
"use client";
import { useState, type ComponentProps, type ReactNode } from "react";
import "./banner.css";
type Tone = "neutral" | "info" | "ok" | "warn" | "danger";
export type BannerProps = Omit<ComponentProps<"div">, "role"> & {
/** Sets the tint and the icon. Say the tone in the wording too. */
tone?: Tone;
/** Adds a close button. The banner collapses and stays hidden. */
dismissible?: boolean;
onDismiss?: () => void;
/** Sticks to the top of the scrolling container. */
sticky?: boolean;
/** Replaces the tone's icon. Neutral has none unless given. */
icon?: ReactNode;
/** Where the icon and text sit: from the start edge, or centred across the banner. */
align?: "start" | "center" | "centre";
};
const iconProps = {
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
/* One shape per tone, so the tone never rests on colour alone. */
const icons: Record<Exclude<Tone, "neutral">, ReactNode> = {
info: (
<>
<circle cx="8" cy="8" r="6.25" />
<path d="M8 7.25v3.5M8 5.25v.01" />
</>
),
ok: (
<>
<circle cx="8" cy="8" r="6.25" />
<path d="M5.5 8.25l1.75 1.75 3.25-3.5" />
</>
),
warn: (
<>
<path d="M8 2.25l6.25 11H1.75z" />
<path d="M8 6.75v3M8 11.5v.01" />
</>
),
danger: (
<>
<path d="M5.4 1.75h5.2l3.65 3.65v5.2l-3.65 3.65H5.4l-3.65-3.65V5.4z" />
<path d="M6 6l4 4M10 6l-4 4" />
</>
),
};
export function Banner({
tone = "neutral",
dismissible = false,
onDismiss,
sticky = false,
icon,
align = "start",
className,
children,
...rest
}: BannerProps) {
const [dismissed, setDismissed] = useState(false);
const glyph = icon ?? (tone === "neutral" ? null : <svg {...iconProps}>{icons[tone]}</svg>);
return (
<div
{...rest}
className={["eq-banner", className].filter(Boolean).join(" ")}
role={tone === "danger" ? "alert" : "status"}
data-tone={tone}
data-align={align === "start" ? undefined : "center"}
data-sticky={sticky || undefined}
data-dismissed={dismissed || undefined}
>
<div className="eq-banner-clip">
<div className="eq-banner-body">
{glyph != null && <span className="eq-banner-icon">{glyph}</span>}
<div className="eq-banner-text">{children}</div>
{dismissible && (
<button
type="button"
className="eq-banner-close"
aria-label="Dismiss"
onClick={() => {
setDismissed(true);
onDismiss?.();
}}
>
<svg {...iconProps}>
<path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}
export type BannerActionProps =
| ({ href: string } & ComponentProps<"a">)
| ({ href?: undefined } & ComponentProps<"button">);
/** An underlined inline action: a link when given href, otherwise a button. */
export function BannerAction(props: BannerActionProps) {
if (props.href !== undefined) {
const { className, ...rest } = props;
return <a {...rest} className={["eq-banner-action", className].filter(Boolean).join(" ")} />;
}
const { className, ...rest } = props;
return <button type="button" {...rest} className={["eq-banner-action", className].filter(Boolean).join(" ")} />;
}components/equip/banner.css184 lines
@layer equip {
.eq-banner {
--_tone: var(--eq-ink-2);
--_tint: var(--eq-tint);
display: grid;
grid-template-rows: 1fr;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
transition:
grid-template-rows var(--eq-dur) var(--eq-ease),
opacity var(--eq-dur) var(--eq-ease),
visibility var(--eq-dur) allow-discrete;
}
.eq-banner[data-tone="info"] {
--_tint: var(--eq-tint-strong);
}
.eq-banner[data-tone="ok"] {
--_tone: var(--eq-ok);
--_tint: color-mix(in srgb, var(--eq-ok) 12%, transparent);
}
.eq-banner[data-tone="warn"] {
--_tone: var(--eq-warn);
--_tint: color-mix(in srgb, var(--eq-warn) 14%, transparent);
}
.eq-banner[data-tone="danger"] {
--_tone: var(--eq-danger);
--_tint: color-mix(in srgb, var(--eq-danger) 12%, transparent);
}
/* Sticky content scrolls underneath, so the tint is laid over the page colour. */
.eq-banner[data-sticky] {
position: sticky;
top: 0;
z-index: 1;
background: var(--eq-bg);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
/* Dismissed: the row collapses to nothing, then the banner leaves the accessibility tree. */
.eq-banner[data-dismissed] {
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
}
.eq-banner-clip {
min-height: 0;
overflow: hidden;
}
.eq-banner-body {
display: flex;
align-items: flex-start;
gap: var(--eq-space-2) var(--eq-space-3);
padding: var(--eq-space-2) var(--eq-space-4);
border-block-end: 1px solid color-mix(in srgb, var(--_tone) 30%, transparent);
background: var(--_tint);
}
/* Centred: icon and text shrink to their content in the middle; the close button is taken out of
the row and both sides keep room for it, so the message stays on the true centre. */
.eq-banner[data-align="center"] .eq-banner-body {
position: relative;
justify-content: center;
}
.eq-banner[data-align="center"] .eq-banner-body:has(> .eq-banner-close) {
padding-inline: calc(var(--eq-space-4) + 24px);
}
.eq-banner[data-align="center"] .eq-banner-text {
flex: 0 1 auto;
text-align: center;
}
.eq-banner[data-align="center"] .eq-banner-close {
position: absolute;
top: calc(var(--eq-space-2) - 2px);
inset-inline-end: calc(var(--eq-space-4) - var(--eq-space-1));
margin: 0;
}
.eq-banner-icon {
display: grid;
flex: none;
margin-top: 2px;
color: var(--_tone);
}
.eq-banner-icon svg {
width: 16px;
height: 16px;
}
.eq-banner-text {
flex: 1;
min-width: 0;
padding-block: 1px;
}
.eq-banner-text > :first-child {
margin-top: 0;
}
.eq-banner-text > :last-child {
margin-bottom: 0;
}
.eq-banner-action {
display: inline-block;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-weight: var(--eq-weight-strong);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
cursor: pointer;
transition:
text-decoration-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-banner-action:active {
scale: var(--eq-press);
}
@media (hover: hover) {
.eq-banner-action:hover {
text-decoration-color: currentColor;
}
.eq-banner-close:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-banner-action:focus-visible,
.eq-banner-close:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
border-radius: var(--eq-radius-s);
}
.eq-banner-close {
display: grid;
place-items: center;
flex: none;
width: 24px;
height: 24px;
margin-block: -2px;
margin-inline-end: calc(-1 * var(--eq-space-1));
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-banner-close:active {
scale: var(--eq-press);
}
.eq-banner-close svg {
width: 16px;
height: 16px;
}
}