Notification Stack
NewCompact notification cards that spring from a stacked summary into a readable list on hover, focus or tap.
Updated
TSXcomponents/previews/blocks/notification-stack.preview.tsx
"use client";
import { RotateCw } from "lucide-react";
import {
NotificationStack,
type NotificationStackItem,
} from "@/components/motion/notification-stack";
const notifications: NotificationStackItem[] = [
{
id: "import-failed",
title: "Orders import failed",
description: "42s · TimeoutError at Step 2",
trailing: (
<span className="inline-flex items-center gap-1 text-amber-500 dark:text-amber-400">
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
2
</span>
),
},
{
id: "sla-breach",
title: "SLA breach",
description: "2m 11s · Data enrichment",
},
{
id: "sync-fixed",
title: "Product sync auto-fixed",
description: "5m · 404 on GET /products",
},
];
export function NotificationStackPreview() {
return (
<div className="flex w-full items-center justify-center pt-52 pb-6">
<NotificationStack items={notifications} />
</div>
);
}
TSXcomponents/motion/notification-stack.tsx
"use client";
// beui.dev/components/blocks/notification-stack
import { ArrowUpRight, BellOff } from "lucide-react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import {
useCallback,
useRef,
useState,
type FocusEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { ActionSwapText } from "@/components/motion/action-swap";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type NotificationStackItem = {
id: string;
title: ReactNode;
description?: ReactNode;
trailing?: ReactNode;
};
export type NotificationStackClassNames = {
stack?: string;
card?: string;
content?: string;
title?: string;
description?: string;
trailing?: string;
footer?: string;
count?: string;
};
export interface NotificationStackProps {
items: NotificationStackItem[];
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onViewAll?: () => void;
maxVisible?: number;
collapsedLabel?: string;
expandedLabel?: string;
emptyLabel?: string;
className?: string;
classNames?: NotificationStackClassNames;
}
const STACK_PEEK = 8;
const STACK_INSET = 12;
function useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
}: {
expanded?: boolean;
defaultExpanded: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = expanded !== undefined;
const value = expanded ?? internalExpanded;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[isControlled, onExpandedChange],
);
return [value, setValue] as const;
}
function NotificationCardContent({
item,
classNames,
}: {
item: NotificationStackItem;
classNames?: NotificationStackClassNames;
}) {
return (
<span
className={cn(
"flex min-w-0 flex-col gap-1.5 py-4",
classNames?.content,
)}
>
<span className="flex min-w-0 items-start justify-between gap-3">
<span
className={cn(
"min-w-0 text-sm font-medium leading-snug",
classNames?.title,
)}
>
{item.title}
</span>
{item.trailing ? (
<span
className={cn("shrink-0 text-xs", classNames?.trailing)}
>
{item.trailing}
</span>
) : null}
</span>
{item.description ? (
<span
className={cn(
"text-xs leading-relaxed text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</span>
) : null}
</span>
);
}
export function NotificationStack({
items,
expanded,
defaultExpanded = false,
onExpandedChange,
onViewAll,
maxVisible = 3,
collapsedLabel = "Notifications",
expandedLabel = "View all",
emptyLabel = "All caught up",
className,
classNames,
}: NotificationStackProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const hasFocus = useRef(false);
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
const visibleItems = items.slice(0, Math.max(1, maxVisible));
const primaryItem = visibleItems[0];
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const cardTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.32, ease: EASE_OUT };
const backgroundTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.26, ease: EASE_OUT };
if (!primaryItem) {
return (
<div
className={cn(
"flex w-full max-w-[22rem] items-center justify-center gap-2 rounded-3xl bg-muted/70 px-5 py-8 text-sm font-medium text-muted-foreground",
className,
)}
>
<BellOff className="h-4 w-4" aria-hidden="true" />
{emptyLabel}
</div>
);
}
const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
hasFocus.current = false;
setIsExpanded(false);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
setIsExpanded(false);
event.currentTarget.blur();
};
const handleClick = () => {
if (!isExpanded) {
setIsExpanded(true);
return;
}
if (onViewAll) {
onViewAll();
return;
}
setIsExpanded(false);
};
return (
<motion.button
type="button"
initial={false}
aria-expanded={isExpanded}
aria-label={
isExpanded
? `${items.length} notifications. ${expandedLabel}.`
: `${items.length} notifications. Expand notifications.`
}
onPointerEnter={() => {
if (canHover) setIsExpanded(true);
}}
onPointerLeave={() => {
if (canHover && !hasFocus.current) setIsExpanded(false);
}}
onFocus={() => {
hasFocus.current = true;
setIsExpanded(true);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={cn(
"relative z-10 block w-full max-w-[22rem] cursor-pointer rounded-3xl text-left text-foreground outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
>
{/* This invisible first card gives the button its compact intrinsic footprint. */}
<span aria-hidden="true" className="invisible block p-3">
<span className="block">
<span
className={cn(
"block rounded-2xl border border-transparent px-4",
classNames?.card,
)}
>
<NotificationCardContent
item={primaryItem}
classNames={classNames}
/>
</span>
</span>
<span className="mt-2 block h-9" />
</span>
<span className="absolute inset-x-0 bottom-0 block p-3">
<motion.span
aria-hidden="true"
layout
initial={false}
transition={backgroundTransition}
className="absolute inset-0 rounded-3xl bg-muted/70"
/>
<span
className={cn(
"relative z-10 grid gap-1",
!isExpanded && "pb-2",
classNames?.stack,
)}
>
{visibleItems.map((item, index) => {
const isPrimary = index === 0;
return (
<motion.span
key={item.id}
layout="position"
initial={false}
animate={{
y: isExpanded ? 0 : index * STACK_PEEK,
clipPath: isExpanded
? "inset(0px 0px round 16px)"
: `inset(0px ${index * STACK_INSET}px round 16px)`,
}}
transition={cardTransition}
className={cn(
"block rounded-2xl border border-border/60 bg-background px-4",
classNames?.card,
)}
style={{
zIndex: visibleItems.length - index,
gridColumn: 1,
gridRow: isExpanded ? index + 1 : 1,
}}
>
<span
className={cn(
"block",
!isPrimary && !isExpanded && "invisible",
)}
>
<NotificationCardContent
item={item}
classNames={classNames}
/>
</span>
</motion.span>
);
})}
</span>
<motion.span
layout="position"
transition={transition}
className={cn(
"relative z-10 mt-2 flex min-h-9 items-center gap-2 px-1",
classNames?.footer,
)}
>
<span
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full bg-orange-600 text-xs font-medium text-white shadow-[inset_0_1px_2px_rgb(0_0_0/0.2),inset_0_-1px_0_rgb(255_255_255/0.16)] dark:bg-orange-500",
classNames?.count,
)}
>
{items.length}
</span>
<span className="flex items-center text-sm font-medium">
<ActionSwapText
value={isExpanded ? "expanded" : "collapsed"}
animation="roll"
>
{isExpanded ? (
<span className="inline-flex items-center gap-1">
{expandedLabel}
<ArrowUpRight className="size-4" aria-hidden="true" />
</span>
) : (
collapsedLabel
)}
</ActionSwapText>
</span>
</motion.span>
</span>
</motion.button>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/notification-stack
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/notification-stack.tsx
"use client";
// beui.dev/components/blocks/notification-stack
import { ArrowUpRight, BellOff } from "lucide-react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import {
useCallback,
useRef,
useState,
type FocusEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { ActionSwapText } from "@/components/motion/action-swap";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type NotificationStackItem = {
id: string;
title: ReactNode;
description?: ReactNode;
trailing?: ReactNode;
};
export type NotificationStackClassNames = {
stack?: string;
card?: string;
content?: string;
title?: string;
description?: string;
trailing?: string;
footer?: string;
count?: string;
};
export interface NotificationStackProps {
items: NotificationStackItem[];
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onViewAll?: () => void;
maxVisible?: number;
collapsedLabel?: string;
expandedLabel?: string;
emptyLabel?: string;
className?: string;
classNames?: NotificationStackClassNames;
}
const STACK_PEEK = 8;
const STACK_INSET = 12;
function useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
}: {
expanded?: boolean;
defaultExpanded: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = expanded !== undefined;
const value = expanded ?? internalExpanded;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[isControlled, onExpandedChange],
);
return [value, setValue] as const;
}
function NotificationCardContent({
item,
classNames,
}: {
item: NotificationStackItem;
classNames?: NotificationStackClassNames;
}) {
return (
<span
className={cn(
"flex min-w-0 flex-col gap-1.5 py-4",
classNames?.content,
)}
>
<span className="flex min-w-0 items-start justify-between gap-3">
<span
className={cn(
"min-w-0 text-sm font-medium leading-snug",
classNames?.title,
)}
>
{item.title}
</span>
{item.trailing ? (
<span
className={cn("shrink-0 text-xs", classNames?.trailing)}
>
{item.trailing}
</span>
) : null}
</span>
{item.description ? (
<span
className={cn(
"text-xs leading-relaxed text-muted-foreground",
classNames?.description,
)}
>
{item.description}
</span>
) : null}
</span>
);
}
export function NotificationStack({
items,
expanded,
defaultExpanded = false,
onExpandedChange,
onViewAll,
maxVisible = 3,
collapsedLabel = "Notifications",
expandedLabel = "View all",
emptyLabel = "All caught up",
className,
classNames,
}: NotificationStackProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const hasFocus = useRef(false);
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
const visibleItems = items.slice(0, Math.max(1, maxVisible));
const primaryItem = visibleItems[0];
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const cardTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.32, ease: EASE_OUT };
const backgroundTransition: Transition = reduce
? { duration: 0 }
: { duration: 0.26, ease: EASE_OUT };
if (!primaryItem) {
return (
<div
className={cn(
"flex w-full max-w-[22rem] items-center justify-center gap-2 rounded-3xl bg-muted/70 px-5 py-8 text-sm font-medium text-muted-foreground",
className,
)}
>
<BellOff className="h-4 w-4" aria-hidden="true" />
{emptyLabel}
</div>
);
}
const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
hasFocus.current = false;
setIsExpanded(false);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
setIsExpanded(false);
event.currentTarget.blur();
};
const handleClick = () => {
if (!isExpanded) {
setIsExpanded(true);
return;
}
if (onViewAll) {
onViewAll();
return;
}
setIsExpanded(false);
};
return (
<motion.button
type="button"
initial={false}
aria-expanded={isExpanded}
aria-label={
isExpanded
? `${items.length} notifications. ${expandedLabel}.`
: `${items.length} notifications. Expand notifications.`
}
onPointerEnter={() => {
if (canHover) setIsExpanded(true);
}}
onPointerLeave={() => {
if (canHover && !hasFocus.current) setIsExpanded(false);
}}
onFocus={() => {
hasFocus.current = true;
setIsExpanded(true);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={cn(
"relative z-10 block w-full max-w-[22rem] cursor-pointer rounded-3xl text-left text-foreground outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
)}
>
{/* This invisible first card gives the button its compact intrinsic footprint. */}
<span aria-hidden="true" className="invisible block p-3">
<span className="block">
<span
className={cn(
"block rounded-2xl border border-transparent px-4",
classNames?.card,
)}
>
<NotificationCardContent
item={primaryItem}
classNames={classNames}
/>
</span>
</span>
<span className="mt-2 block h-9" />
</span>
<span className="absolute inset-x-0 bottom-0 block p-3">
<motion.span
aria-hidden="true"
layout
initial={false}
transition={backgroundTransition}
className="absolute inset-0 rounded-3xl bg-muted/70"
/>
<span
className={cn(
"relative z-10 grid gap-1",
!isExpanded && "pb-2",
classNames?.stack,
)}
>
{visibleItems.map((item, index) => {
const isPrimary = index === 0;
return (
<motion.span
key={item.id}
layout="position"
initial={false}
animate={{
y: isExpanded ? 0 : index * STACK_PEEK,
clipPath: isExpanded
? "inset(0px 0px round 16px)"
: `inset(0px ${index * STACK_INSET}px round 16px)`,
}}
transition={cardTransition}
className={cn(
"block rounded-2xl border border-border/60 bg-background px-4",
classNames?.card,
)}
style={{
zIndex: visibleItems.length - index,
gridColumn: 1,
gridRow: isExpanded ? index + 1 : 1,
}}
>
<span
className={cn(
"block",
!isPrimary && !isExpanded && "invisible",
)}
>
<NotificationCardContent
item={item}
classNames={classNames}
/>
</span>
</motion.span>
);
})}
</span>
<motion.span
layout="position"
transition={transition}
className={cn(
"relative z-10 mt-2 flex min-h-9 items-center gap-2 px-1",
classNames?.footer,
)}
>
<span
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full bg-orange-600 text-xs font-medium text-white shadow-[inset_0_1px_2px_rgb(0_0_0/0.2),inset_0_-1px_0_rgb(255_255_255/0.16)] dark:bg-orange-500",
classNames?.count,
)}
>
{items.length}
</span>
<span className="flex items-center text-sm font-medium">
<ActionSwapText
value={isExpanded ? "expanded" : "collapsed"}
animation="roll"
>
{isExpanded ? (
<span className="inline-flex items-center gap-1">
{expandedLabel}
<ArrowUpRight className="size-4" aria-hidden="true" />
</span>
) : (
collapsedLabel
)}
</ActionSwapText>
</span>
</motion.span>
</span>
</motion.button>
);
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(6px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "115%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-115%",
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 16, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -16,
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API Reference
itemsNotificationStackItem[]—expanded?boolean—defaultExpanded?booleanfalseonExpandedChange?((expanded: boolean) => void)—onViewAll?(() => void)—maxVisible?number3collapsedLabel?stringNotificationsexpandedLabel?stringView allemptyLabel?stringAll caught upclassName?string—classNames?NotificationStackClassNames—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.