Notification Stack
Compact notification cards that spring from a stacked summary into a readable list on hover, focus or tap.
Preview
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, type Transition, useReducedMotion } from "motion/react";
import {
type FocusEvent,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { ActionSwapText } from "@/components/motion/action-swap";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
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 hasFocus = useRef(false);
const rootRef = useRef<HTMLButtonElement>(null);
const hover = useHoverGesture();
// What the last gesture on the stack was, and whether it was already
// expanded when that gesture started. A click reports neither.
const tap = useTapGesture<boolean>();
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
// Set by the tap that expands the stack, and the reason the outside-tap
// dismisser exists at all — a hovering pointer has its own way out.
const [tapExpanded, setTapExpanded] = useState(false);
const collapse = useCallback(() => {
setTapExpanded(false);
setIsExpanded(false);
}, [setIsExpanded]);
// A pointer leaving the stack is what collapses it, and a finger never
// leaves: without this the stack stays open for good once tapped, and when
// `onViewAll` is set the next tap follows the link instead of closing. The
// tap that lands somewhere else stands in for the pointer leaving, and it is
// consumed rather than passed through — the expanded stack covers the page,
// so the tap that dismisses it is aimed at nothing else.
useDismiss(tapExpanded && isExpanded, collapse, rootRef, {
behavior: "consume",
});
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;
collapse();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
// A key press is the start of a keyboard activation, never part of a tap:
// whatever a taken-away gesture left behind must not be read as one.
tap.drop();
if (event.key !== "Escape") return;
event.preventDefault();
collapse();
// Focus stays where the keyboard put it. Blurring here collapsed the stack
// *and* threw the user back to the top of the document.
};
const handleClick = () => {
const gesture = tap.take();
// Read from where the gesture started, not from now: a browser that
// focuses the stack on contact expands it mid-tap, and the first tap would
// then follow `onViewAll` instead of opening the list it was meant to.
const wasExpanded = gesture ? gesture.state : isExpanded;
if (!wasExpanded) {
setIsExpanded(true);
if (gesture && gesture.pointerType !== "mouse") setTapExpanded(true);
return;
}
if (onViewAll) {
onViewAll();
return;
}
collapse();
};
return (
<motion.button
ref={rootRef}
type="button"
initial={false}
aria-expanded={isExpanded}
aria-label={
isExpanded
? `${items.length} notifications. ${expandedLabel}.`
: `${items.length} notifications. Expand notifications.`
}
// A tap reports as a hover on its way past — enter, leave, then click —
// so an unfiltered hover path expands, collapses and expands again in
// the space of one tap, springs and all. The tap has its own route
// through `handleClick`.
onPointerEnter={(event: PointerEvent<HTMLButtonElement>) => {
if (hover.enter(event)) setIsExpanded(true);
}}
onPointerLeave={(event: PointerEvent<HTMLButtonElement>) => {
if (hover.leave(event) && !hasFocus.current) collapse();
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
tap.start(event, isExpanded);
}}
// The platform can take the gesture away mid-press — a scroll, a system
// swipe — and no click follows it.
onPointerCancel={tap.drop}
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"
/>
<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;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/hooks/use-dismiss.ts
"use client";
import { type RefObject, useEffect } from "react";
/**
* What the dismissing gesture does to the control it landed on.
*
* `"pass-through"` is the platform norm (native popover light-dismiss): the
* tap closes the overlay *and* activates whatever was under it. Use
* `"consume"` where the open overlay sits over or beside controls that would
* be costly to trigger by accident — the dismissal then swallows the
* activation too, so the gesture only closes.
*/
export type DismissBehavior = "pass-through" | "consume";
export interface DismissOptions {
/** Default `"pass-through"`. */
behavior?: DismissBehavior;
/** Dismiss on Escape as well. Default true. */
escape?: boolean;
/** Return true for an outside target that should *not* dismiss. Must be stable. */
ignore?: (target: Element) => boolean;
}
/**
* What every currently open dismiss scope counts as inside itself. A consumed
* dismissal reads this to tell a stray gesture from one that belongs to an
* overlay in front of it: overlays have no shared z-order to consult, but the
* one the gesture landed in has said as much by registering it.
*/
const openScopes = new Set<(target: Element) => boolean>();
function claimedByAnotherScope(
self: (target: Element) => boolean,
target: Element,
) {
for (const scope of openScopes) {
if (scope !== self && scope(target)) return true;
}
return false;
}
// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
const swallow = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
release();
};
const restart = (event: Event) => {
if (event !== source) release();
};
const release = () => {
window.removeEventListener("click", swallow, true);
window.removeEventListener("pointerdown", restart, true);
window.removeEventListener("pointercancel", restart, true);
window.removeEventListener("keydown", release, true);
};
window.addEventListener("click", swallow, true);
window.addEventListener("pointerdown", restart, true);
window.addEventListener("pointercancel", restart, true);
window.addEventListener("keydown", release, true);
}
/**
* Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
* for `ref` when what counts as inside isn't one element, and say so with
* `ignore` instead.
*
* The pointerdown listener is capture-phase: a bubble-phase one is blinded by
* any handler in between that stops propagation, and an overlay cannot know
* what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
* useCallback) so the listeners aren't re-bound every render while open.
*/
export function useDismiss(
open: boolean,
onDismiss: () => void,
ref: RefObject<HTMLElement | null> | null,
{
behavior = "pass-through",
escape: dismissOnEscape = true,
ignore,
}: DismissOptions = {},
) {
useEffect(() => {
if (!open) return;
const inside = (target: Element) =>
Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
const onKey = (event: KeyboardEvent) => {
if (dismissOnEscape && event.key === "Escape") onDismiss();
};
const onPointer = (event: PointerEvent) => {
const target = event.target as Element | null;
if (!target || inside(target)) return;
// Outside this overlay, but inside one that is also open: the gesture is
// that overlay's to answer, and swallowing its click from behind would
// cost the user the control they actually aimed at.
if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
consumeActivation(event);
}
onDismiss();
};
openScopes.add(inside);
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer, true);
return () => {
openScopes.delete(inside);
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer, true);
};
}, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
TSXlib/hooks/use-hover-gesture.ts
"use client";
import { useMemo, useRef } from "react";
import { isHoveringPointer } from "@/lib/touch";
interface BoundaryEvent {
pointerId: number;
pointerType: string;
buttons: number;
}
export interface HoverGesture {
/** True when this enter starts a hover: the pointer arrived resting, not pressing. */
enter: (event: BoundaryEvent) => boolean;
/** True when this leave ends a hover that entered as one. */
leave: (event: BoundaryEvent) => boolean;
}
/**
* Pairs a surface's enter with its leave, per pointer.
*
* `isHoveringPointer` answers the question the *enter* asks — is this pointer
* resting on the surface or pressing it — and both boundary cases go wrong if
* the leave is asked the same question again:
*
* - A pen with no hover never rests. It arrives in contact, taps, and the spec
* then requires its boundary events after `pointerup`, so the leave carries
* `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid
* the tap — the panel the pen had just opened closed under it.
* - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.
* Skipping teardown there strands the surface open: the release happens
* outside, and no second leave ever comes.
*
* So the state a hover holds is released by the pointer that took it, whatever
* the buttons say at the boundary, and a pointer that arrived in contact never
* took it in the first place. Contact is the exception tracked here, not
* hover: a leave from a pointer this surface never saw enter — mounted under
* the cursor, say — still counts, since the alternative is state with no way
* out.
*/
export function useHoverGesture(): HoverGesture {
const contact = useRef(new Set<number>());
return useMemo(
() => ({
enter: (event) => {
if (isHoveringPointer(event)) {
contact.current.delete(event.pointerId);
return true;
}
contact.current.add(event.pointerId);
return false;
},
leave: (event) => {
const arrivedInContact = contact.current.delete(event.pointerId);
return !arrivedInContact && event.pointerType !== "touch";
},
}),
[],
);
}
TSXlib/hooks/use-tap-gesture.ts
"use client";
import { useMemo, useRef } from "react";
/** What a pointerdown recorded, read back by the click that ends its gesture. */
export interface TapRecord<S> {
/** Which input started the gesture. */
pointerType: string;
/** What the surface was showing when it started. */
state: S;
}
export interface TapGesture<S> {
/** Record the gesture a pointerdown starts, with the state it starts in. */
start: (event: { pointerType: string }, state: S) => void;
/** Read the record and clear it. `null` when no pointer is behind this click. */
take: () => TapRecord<S> | null;
/** Drop the record: this gesture will never spend it on a click. */
drop: () => void;
}
/**
* The pointer gesture behind a click, recorded where the click cannot report
* it. A `click` carries no `pointerType` in the engines that matter, so the
* `pointerdown` before it is the only thing that says which input activated
* the control — and whether one did at all, since keyboard activation
* synthesizes a click with no pointer behind it.
*
* State goes in with the record because a click reports that no better: a
* browser that focuses a control on contact can open the very panel the tap
* was meant to open, and reading "is it open" at click time then undoes it.
* What the gesture started against is what it acts on.
*
* The record is spent by one click and dropped by everything else, because a
* record that outlives its gesture is worse than none:
*
* - A scroll or an OS gesture takes the touch away — `pointercancel`, no click
* ever — and the finger would sit in the record until some later click.
* - That later click is often `Enter` on a keyboard, which arrives with no
* pointerdown of its own and would inherit the abandoned finger. A keydown
* is the start of a keyboard activation and never part of a tap, so it drops
* the record too.
*
* Both ends have to be wired by the surface: `drop` on `onPointerCancel` and
* on `onKeyDown`.
*/
export function useTapGesture<S>(): TapGesture<S> {
const record = useRef<TapRecord<S> | null>(null);
return useMemo(
() => ({
start: (event, state) => {
record.current = { pointerType: event.pointerType, state };
},
take: () => {
const spent = record.current;
record.current = null;
return spent;
},
drop: () => {
record.current = null;
},
}),
[],
);
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
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, type Transition, useReducedMotion } from "motion/react";
import {
type FocusEvent,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { ActionSwapText } from "@/components/motion/action-swap";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useHoverGesture } from "@/lib/hooks/use-hover-gesture";
import { useTapGesture } from "@/lib/hooks/use-tap-gesture";
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 hasFocus = useRef(false);
const rootRef = useRef<HTMLButtonElement>(null);
const hover = useHoverGesture();
// What the last gesture on the stack was, and whether it was already
// expanded when that gesture started. A click reports neither.
const tap = useTapGesture<boolean>();
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
// Set by the tap that expands the stack, and the reason the outside-tap
// dismisser exists at all — a hovering pointer has its own way out.
const [tapExpanded, setTapExpanded] = useState(false);
const collapse = useCallback(() => {
setTapExpanded(false);
setIsExpanded(false);
}, [setIsExpanded]);
// A pointer leaving the stack is what collapses it, and a finger never
// leaves: without this the stack stays open for good once tapped, and when
// `onViewAll` is set the next tap follows the link instead of closing. The
// tap that lands somewhere else stands in for the pointer leaving, and it is
// consumed rather than passed through — the expanded stack covers the page,
// so the tap that dismisses it is aimed at nothing else.
useDismiss(tapExpanded && isExpanded, collapse, rootRef, {
behavior: "consume",
});
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;
collapse();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
// A key press is the start of a keyboard activation, never part of a tap:
// whatever a taken-away gesture left behind must not be read as one.
tap.drop();
if (event.key !== "Escape") return;
event.preventDefault();
collapse();
// Focus stays where the keyboard put it. Blurring here collapsed the stack
// *and* threw the user back to the top of the document.
};
const handleClick = () => {
const gesture = tap.take();
// Read from where the gesture started, not from now: a browser that
// focuses the stack on contact expands it mid-tap, and the first tap would
// then follow `onViewAll` instead of opening the list it was meant to.
const wasExpanded = gesture ? gesture.state : isExpanded;
if (!wasExpanded) {
setIsExpanded(true);
if (gesture && gesture.pointerType !== "mouse") setTapExpanded(true);
return;
}
if (onViewAll) {
onViewAll();
return;
}
collapse();
};
return (
<motion.button
ref={rootRef}
type="button"
initial={false}
aria-expanded={isExpanded}
aria-label={
isExpanded
? `${items.length} notifications. ${expandedLabel}.`
: `${items.length} notifications. Expand notifications.`
}
// A tap reports as a hover on its way past — enter, leave, then click —
// so an unfiltered hover path expands, collapses and expands again in
// the space of one tap, springs and all. The tap has its own route
// through `handleClick`.
onPointerEnter={(event: PointerEvent<HTMLButtonElement>) => {
if (hover.enter(event)) setIsExpanded(true);
}}
onPointerLeave={(event: PointerEvent<HTMLButtonElement>) => {
if (hover.leave(event) && !hasFocus.current) collapse();
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
tap.start(event, isExpanded);
}}
// The platform can take the gesture away mid-press — a scroll, a system
// swipe — and no click follows it.
onPointerCancel={tap.drop}
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"
/>
<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 { useState } from "react";
import type { ReactNode } from "react";
import { EASE_OUT, 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 = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// 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: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
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: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
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();
// 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 -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom",
className,
)}
style={{
clipPath: "inset(0 -999px)",
WebkitClipPath: "inset(0 -999px)",
}}
>
<span
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: 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.08em] 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"}
// Truncation lives on the layer that holds the text — the layer
// moves as a whole, so clipping it never eats the roll.
className="absolute left-0 top-[0.08em] inline-block max-w-full truncate 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
items{}—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.
Updated