Overlays & Feedbackv0.1.0
Banner Stack
Banners stacked three deep; dismissing reorders the deck.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/banner-stack.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --link, --success, and --warning, which shadcn does not define by default. Add them to your global CSS (--link falls back to your --primary unless you set it):
app/globals.css:root { --success: #15803d; --warning: #b45309; } .dark { --success: #4ade80; --warning: #fbbf24; } @theme inline { --color-link: var(--link, var(--primary)); --color-success: var(--success); --color-warning: var(--warning); }Copy the source
components/ui/banner-stack/banner-stack.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { CircleAlert, CircleCheck, Info, Megaphone, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface Banner { /** Stable id, used for dismissal. */ id: string; /** Main line. */ title: ReactNode; /** Secondary line. */ description?: ReactNode; /** Icon and accent color. */ tone?: "info" | "success" | "warning" | "promo"; /** Replaces the tone's default icon. */ icon?: ReactNode; /** One call to action. */ action?: { label: string; onClick: () => void }; } export interface BannerStackProps extends Omit<ComponentProps<"section">, "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Banners, frontmost first. */ banners: Banner[]; /** Called after a banner is dismissed. The stack also hides it on its own. */ onDismiss?: (id: string) => void; /** How many cards are visible in the stack. @default 3 */ depth?: number; } const toneStyles = { info: { chip: "bg-primary/10 text-link", icon: Info }, success: { chip: "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]", icon: CircleCheck }, warning: { chip: "bg-warning/12 text-warning", icon: CircleAlert }, promo: { chip: "bg-foreground text-background", icon: Megaphone }, } as const; // Each card behind the front one peeks out by this much. const PEEK = 10; export function BannerStack({ banners, onDismiss, depth = 3, className, "aria-label": ariaLabel, ...props }: BannerStackProps) { const [dismissed, setDismissed] = useState<Set<string>>(() => new Set()); const [frontHeight, setFrontHeight] = useState(0); const frontRef = useRef<HTMLDivElement>(null); const reduceMotion = useReducedMotion(); const visible = banners.filter((b) => !dismissed.has(b.id)); const front = visible[0]; const behind = Math.min(visible.length - 1, depth - 1); // The container reserves the front card's height plus the peeking edges behind it. useEffect(() => { const el = frontRef.current; if (!el) return; const observer = new ResizeObserver(() => setFrontHeight(el.offsetHeight)); observer.observe(el); return () => observer.disconnect(); }, [front?.id]); function dismiss(id: string) { setDismissed((current) => new Set(current).add(id)); onDismiss?.(id); } if (!front) return null; return ( <section aria-label={ariaLabel ?? "Announcements"} className={cn("relative w-full", className)} style={{ height: frontHeight ? frontHeight + behind * PEEK : undefined }} {...props} > <AnimatePresence initial={false}> {visible.slice(0, depth + 1).map((banner, index) => { const isFront = index === 0; const tone = toneStyles[banner.tone ?? "info"]; const Icon = tone.icon; return ( <motion.div key={banner.id} ref={isFront ? frontRef : undefined} aria-hidden={!isFront || undefined} inert={!isFront || undefined} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: depth * PEEK, scale: 1 - depth * 0.04 }} animate={{ opacity: index < depth ? 1 : 0, y: reduceMotion ? 0 : index * PEEK, scale: reduceMotion ? 1 : 1 - index * 0.04, }} exit={ reduceMotion ? { opacity: 0, transition: { duration: duration.quick } } : { opacity: 0, y: -14, scale: 1.02, filter: "blur(3px)", transition: { duration: duration.base, ease: ease.out } } } transition={reduceMotion ? { duration: duration.quick } : { type: "spring", stiffness: 360, damping: 30 }} style={{ zIndex: depth + 1 - index }} className={cn( "absolute inset-x-0 top-0 origin-bottom rounded-[14px] border border-border bg-popover text-popover-foreground shadow-md", // Cards behind keep the front card's height so only their edge shows. !isFront && "overflow-hidden", )} > <div style={!isFront && frontHeight ? { height: frontHeight } : undefined} className={cn("flex items-start gap-3 p-4 pr-11", !isFront && "opacity-0")} > <span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4", tone.chip)} aria-hidden="true"> {banner.icon ?? <Icon />} </span> <div className="min-w-0 flex-1"> <p className="text-sm font-medium text-foreground">{banner.title}</p> {banner.description ? ( <p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{banner.description}</p> ) : null} <div className="mt-2.5 flex items-center gap-3"> {banner.action ? ( <button type="button" onClick={banner.action.onClick} className="inline-flex h-7 cursor-pointer items-center rounded-md bg-foreground px-2.5 text-xs font-medium text-background outline-none hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-ring" > {banner.action.label} </button> ) : null} {isFront && visible.length > 1 ? ( <span className="text-xs text-muted-foreground tabular-nums"> {visible.length - 1} more </span> ) : null} </div> </div> <button type="button" aria-label="Dismiss announcement" onClick={() => dismiss(banner.id)} className="absolute top-3 right-3 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-4" aria-hidden="true" /> </button> </div> </motion.div> ); })} </AnimatePresence> <span className="sr-only" aria-live="polite"> {visible.length > 1 ? `${visible.length} announcements` : "1 announcement"} </span> </section> ); }
Usage
import { BannerStack } from "@/components/ui/banner-stack/banner-stack";
export function Announcements({ items }: { items: Banner[] }) {
return <BannerStack banners={items} onDismiss={(id) => markSeen(id)} />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
banners* | Banner[] | – | Banners, frontmost first. |
onDismiss | (id: string) => void | – | Called after a banner is dismissed. The stack also hides it on its own. |
depth | number | 3 | How many cards are visible in the stack. |
Also accepts every native <section> attribute, including ref and className.
- Cards behind the front one take its height, so only their edges peek out like a deck; dismissing springs the next card forward.
- The stack hides dismissed banners itself and reports them through onDismiss so you can persist them.
Accessibility
- The stack is a labelled region ("Announcements"). Only the front card is interactive; cards behind are inert and aria-hidden.
- Each card has a labelled dismiss button, and a polite live region announces how many announcements remain.
- Tone is shown with an icon as well as a color, and the text stays in the foreground color for contrast.
- With reduced motion enabled, cards fade in place instead of stacking, lifting and springing forward.