## Collapsible Banner — Notification
Folds to its title, or lets go entirely.
Docs: https://www.interior.dev/docs/collapsible-banner
Reference: https://www.interior.dev/reference/collapsible-banner
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/collapsible-banner.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { CollapsibleBanner } from "@/components/interior/collapsible-banner";
export function BillingHeader() {
return (
{/* An active incident may be folded out of the way, never removed. */}
localStorage.setItem("billing-notice", "seen")}
action={
Update payment method
}
/>
);
}
```
### Props
- `title` (`React.ReactNode`)
The one line the banner exists to say, and the only thing left when it is folded. Names the region for screen readers.
- `description` (`React.ReactNode`)
The part that folds away. Omitted entirely rather than reserved as empty space.
- `children` (`React.ReactNode`)
Extra content below the description, inside the fold.
- `action` (`React.ReactNode`)
The control the notice is asking for, rendered at the bottom of the fold. Unreachable by keyboard while folded.
- `icon` (`React.ReactNode`) — default: `info glyph`
Replaces 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.
- `dismissible` (`boolean`) — default: `true`
False 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` (`BannerState`) — default: `"open"`
Uncontrolled starting state. Starting folded costs no flash: the body is laid out at zero height on first paint.
- `onStateChange` (`(state: BannerState) => void`)
Fires on every transition, so a folded notice can be remembered per user rather than re-opened on each visit.
- `onDismiss` (`() => void`)
Fires only on dismiss, once.
- `dismissLabel` (`string`) — default: `"Dismiss notice"`
Accessible name of the close button. Set it per banner when a page has several.
- `dismissedMessage` (`string`) — default: `"Notice dismissed."`
Announced once, from a live region outside the collapsing frame, so the confirmation is not clipped away with it.
- `className` (`string`) — default: `""`
Appended to the banner surface. Margins set here sit inside the collapsing box, so a dismissed banner leaves no residual gap.
### Behavior notes
- Severity is carried by the sentence, not by a coloured icon. Three notices in a column read as three messages rather than three components, and the one that matters is the one you wrote the clearest.
- A notice has three resting places, not two. It can be read, folded down to the line that names it, or let go. A banner whose only control is a close button is not collapsible — it is disposable, and the difference matters when the thing it says is still true after you stop looking at it.
- `dismissible={false}` is the point of the fold. An incident, an overdue invoice, a degraded region: you may move it out of the way, you may not make it untrue. Without the fold the only options are a permanent wall of text or a lie.
- The header never moves. Only the body's box changes, so the title, the mark and both controls hold still while the notice folds underneath them — the fold reads as the box closing, not as the page reflowing.
- Opacity leads on the way out and trails 50ms on the way in. Finishing first on the fold is what hides the reflow; starting late on the unfold keeps text from appearing in a box that has not opened yet.
- Height animates to `auto` and is measured by the animation itself, so a description that grows — a longer error, a second line after a webfont lands — needs no ResizeObserver and no measured pixel value living in React state.
- The folded body is inert, so a zero-height notice cannot be tabbed into or read out, and the toggle carries aria-expanded and aria-controls rather than a decorative chevron. Escape folds an open banner from its own header.
- Under prefers-reduced-motion every height, opacity and rotation resolves on the same frame: the notice is still folded, still gone, still correct, only the trip is skipped.
### Source (`components/interior/collapsible-banner.tsx`)
```tsx
"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(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 = (
);
const CARET_DOWN = (
);
const CLOSE = (
);
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 (
<>