Skip to content

Banner Stack

Banners stacked three deep; dismissing reorders the deck.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/banner-stack.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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);
    }
  4. 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

PropTypeDefaultDescription
banners*Banner[]–Banners, frontmost first.
onDismiss(id: string) => void–Called after a banner is dismissed. The stack also hides it on its own.
depthnumber3How 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.