## 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 ( <>
{hasBody ? ( ) : ( {title} )} {dismissible ? ( ) : null}
{hasBody ? ( {description ? (

{description}

) : null} {children} {action ?
{action}
: null}
) : null}
{state === "dismissed" ? dismissedMessage : ""} ); } ```