Notification·04.2
Collapsible Banner
Folds to its title, or lets go entirely.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
"use client";
import { CollapsibleBanner } from "@/components/interior/collapsible-banner";
export function BillingHeader() {
return (
<div className="space-y-2">
{/* An active incident may be folded out of the way, never removed. */}
<CollapsibleBanner
tone="danger"
dismissible={false}
title="Payments are degraded"
description="Charges are queued and settle automatically once the provider recovers."
/>
<CollapsibleBanner
tone="warning"
title="Your card was declined"
description="We retry on the 14th. Invoices stay open until it clears."
dismissLabel="Dismiss billing notice"
onDismiss={() => localStorage.setItem("billing-notice", "seen")}
action={
<a
href="/settings/billing"
className="inline-flex h-7 items-center rounded-[7px] border border-stone-200 px-2.5 text-[11.5px] font-medium text-stone-700 dark:border-white/[0.16] dark:text-stone-200"
>
Update payment method
</a>
}
/>
<InvoiceTable />
</div>
);
}Source
"use client";
import { useCallback, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
const EASE = [0.23, 1, 0.32, 1] as const;
const DISCLOSE = { type: "spring", stiffness: 190, damping: 30, mass: 1 } as const;
const NUDGE = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const;
const INSTANT = { duration: 0 } as const;
export type BannerState = "open" | "folded" | "dismissed";
export type UseCollapsibleBannerOptions = {
state?: BannerState;
defaultState?: BannerState;
onStateChange?: (state: BannerState) => void;
onDismiss?: () => void;
};
export type UseCollapsibleBannerResult = {
state: BannerState;
open: boolean;
folded: boolean;
dismissed: boolean;
fold: () => void;
expand: () => void;
toggle: () => void;
dismiss: () => void;
restore: () => void;
};
export function useCollapsibleBanner({
state: controlled,
defaultState = "open",
onStateChange,
onDismiss,
}: UseCollapsibleBannerOptions = {}): UseCollapsibleBannerResult {
const [uncontrolled, setUncontrolled] = useState<BannerState>(defaultState);
const state = controlled ?? uncontrolled;
const changed = useRef(onStateChange);
changed.current = onStateChange;
const closed = useRef(onDismiss);
closed.current = onDismiss;
const commit = useCallback((next: BannerState) => {
setUncontrolled(next);
changed.current?.(next);
}, []);
const fold = useCallback(() => commit("folded"), [commit]);
const expand = useCallback(() => commit("open"), [commit]);
const restore = useCallback(() => commit("open"), [commit]);
const toggle = useCallback(
() => commit(state === "open" ? "folded" : "open"),
[commit, state],
);
const dismiss = useCallback(() => {
commit("dismissed");
closed.current?.();
}, [commit]);
return {
state,
open: state === "open",
folded: state === "folded",
dismissed: state === "dismissed",
fold,
expand,
toggle,
dismiss,
restore,
};
}
const NOTICE_GLYPH = (
<svg width="16" height="16" viewBox="0 0 256 256" fill="none" aria-hidden="true">
<circle cx="128" cy="128" r="96" stroke="currentColor" strokeWidth="16" />
<polyline
points="120 120 128 120 128 176 136 176"
stroke="currentColor"
strokeWidth="16"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="124" cy="84" r="12" fill="currentColor" />
</svg>
);
const CARET_DOWN = (
<svg width="14" height="14" viewBox="0 0 256 256" fill="none" aria-hidden="true">
<polyline
points="208 96 128 176 48 96"
stroke="currentColor"
strokeWidth="16"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CLOSE = (
<svg width="13" height="13" viewBox="0 0 256 256" fill="none" aria-hidden="true">
<line
x1="200"
y1="56"
x2="56"
y2="200"
stroke="currentColor"
strokeWidth="16"
strokeLinecap="round"
/>
<line
x1="200"
y1="200"
x2="56"
y2="56"
stroke="currentColor"
strokeWidth="16"
strokeLinecap="round"
/>
</svg>
);
export type CollapsibleBannerProps = {
title: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
action?: React.ReactNode;
icon?: React.ReactNode;
dismissible?: boolean;
state?: BannerState;
defaultState?: BannerState;
onStateChange?: (state: BannerState) => void;
onDismiss?: () => void;
dismissLabel?: string;
dismissedMessage?: string;
className?: string;
};
export function CollapsibleBanner({
title,
description,
children,
action,
icon,
dismissible = true,
state: controlled,
defaultState = "open",
onStateChange,
onDismiss,
dismissLabel = "Dismiss notice",
dismissedMessage = "Notice dismissed.",
className = "",
}: CollapsibleBannerProps) {
const reduced = useReducedMotion();
const uid = useId();
const bodyId = `${uid}-body`;
const titleId = `${uid}-title`;
const { state, open, dismissed, toggle, fold, dismiss } = useCollapsibleBanner({
state: controlled,
defaultState,
onStateChange,
onDismiss,
});
const hasBody = Boolean(description || children || action);
const disclose = reduced
? INSTANT
: {
height: DISCLOSE,
opacity: { duration: 0.14, ease: EASE, delay: open ? 0.05 : 0 },
y: DISCLOSE,
};
return (
<>
<motion.div
initial={false}
animate={{ height: dismissed ? 0 : "auto", opacity: dismissed ? 0 : 1 }}
transition={
reduced
? INSTANT
: { height: DISCLOSE, opacity: { duration: 0.14, ease: EASE } }
}
style={{ overflow: "hidden" }}
className="rounded-[11px]"
>
<div
role="region"
aria-labelledby={titleId}
className={`rounded-[11px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_6px_rgba(0,0,0,0.45)] ${className}`}
>
<div className="flex items-center gap-2.5 p-2.5">
<span
aria-hidden="true"
className="grid size-[26px] shrink-0 place-items-center rounded-[7px] bg-stone-100/70 text-stone-500 shadow-[inset_0_1px_2px_rgba(28,25,23,0.06)] dark:bg-[#252522] dark:text-stone-400 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.4)]"
>
{icon ?? NOTICE_GLYPH}
</span>
{hasBody ? (
<button
type="button"
onClick={toggle}
onKeyDown={(e) => {
if (e.key !== "Escape" || !open) return;
e.stopPropagation();
fold();
}}
aria-expanded={open}
aria-controls={bodyId}
className="group flex min-w-0 flex-1 items-center gap-2 rounded-[7px] text-left outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
<span
id={titleId}
className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-100"
>
{title}
</span>
<motion.span
aria-hidden="true"
className="flex shrink-0 text-stone-400 group-hover:text-stone-600 dark:text-stone-500 dark:group-hover:text-stone-300"
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduced ? INSTANT : NUDGE}
>
{CARET_DOWN}
</motion.span>
</button>
) : (
<span
id={titleId}
className="min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-100"
>
{title}
</span>
)}
{dismissible ? (
<button
type="button"
onClick={dismiss}
aria-label={dismissLabel}
className="grid size-[26px] shrink-0 place-items-center rounded-[7px] text-stone-400 transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
>
{CLOSE}
</button>
) : null}
</div>
{hasBody ? (
<motion.div
id={bodyId}
inert={!open}
initial={false}
animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
transition={disclose}
style={{ overflow: "hidden" }}
>
<motion.div
initial={false}
animate={{ y: open ? 0 : -6 }}
transition={reduced ? INSTANT : DISCLOSE}
className="pb-2.5 pl-[46px] pr-2.5"
>
{description ? (
<p className="text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400">
{description}
</p>
) : null}
{children}
{action ? <div className="mt-2">{action}</div> : null}
</motion.div>
</motion.div>
) : null}
</div>
</motion.div>
<span role="status" aria-live="polite" className="sr-only">
{state === "dismissed" ? dismissedMessage : ""}
</span>
</>
);
}Props
titleReact.ReactNodeThe one line the banner exists to say, and the only thing left when it is folded. Names the region for screen readers.
descriptionReact.ReactNodeThe part that folds away. Omitted entirely rather than reserved as empty space.
childrenReact.ReactNodeExtra content below the description, inside the fold.
actionReact.ReactNodeThe control the notice is asking for, rendered at the bottom of the fold. Unreachable by keyboard while folded.
iconinfo glyphReact.ReactNodeReplaces the leading mark. The defaults are Phosphor regular geometry inlined, so the icons match the rest of the set without the file taking on an icon package.
dismissibletruebooleanFalse removes the close button and leaves only the fold. This is the prop for a notice that must stay reachable — an active incident, an unpaid invoice.
state"open" | "folded" | "dismissed"Controlled state. When passed, the component never changes itself, it only reports.
defaultState"open"BannerStateUncontrolled starting state. Starting folded costs no flash: the body is laid out at zero height on first paint.
onStateChange(state: BannerState) => voidFires on every transition, so a folded notice can be remembered per user rather than re-opened on each visit.
onDismiss() => voidFires only on dismiss, once.
dismissLabel"Dismiss notice"stringAccessible name of the close button. Set it per banner when a page has several.
dismissedMessage"Notice dismissed."stringAnnounced once, from a live region outside the collapsing frame, so the confirmation is not clipped away with it.
className""stringAppended to the banner surface. Margins set here sit inside the collapsing box, so a dismissed banner leaves no residual gap.