Stack
A component that displays items in a stacked layout with hover expansion effects, similar to Sonner toast stacking.
import { Stack, StackItem } from "@/components/ui/stack";
export function StackDemo() {
return (
<Stack className="w-[360px]" expandOnHover>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Notification 1</h3>
<p className="text-muted-foreground text-sm">
Your deployment was successful
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Notification 2</h3>
<p className="text-muted-foreground text-sm">
New message from John Doe
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Notification 3</h3>
<p className="text-muted-foreground text-sm">
Update available for your app
</p>
</StackItem>
</Stack>
);
}Installation
CLI
npx shadcn@latest add @diceui/stackManual
Install the following dependencies:
npm install @radix-ui/react-slotCopy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ItemDimension {
itemId: number;
size: number;
}
type Side = "top" | "bottom";
function getDataState(isExpanded: boolean) {
return isExpanded ? "expanded" : "collapsed";
}
interface StackContextValue {
side: Side;
childrenCount: number;
itemCount: number;
expandedItemCount: number;
gap: number;
scale: number;
offset: number;
expandOnHover: boolean;
isExpanded: boolean;
isInteracting: boolean;
dimensions: ItemDimension[];
setDimensions: React.Dispatch<React.SetStateAction<ItemDimension[]>>;
}
const StackContext = React.createContext<StackContextValue | null>(null);
function useStackContext(consumerName: string) {
const context = React.useContext(StackContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`Stack\``);
}
return context;
}
interface StackProps extends React.ComponentProps<"div"> {
side?: Side;
itemCount?: number;
expandedItemCount?: number;
gap?: number;
scale?: number;
offset?: number;
expandOnHover?: boolean;
asChild?: boolean;
}
function Stack(props: StackProps) {
const {
side = "bottom",
itemCount = 3,
expandedItemCount,
gap = 8,
scale = 0.05,
offset = 10,
className,
children,
style,
onMouseEnter: onMouseEnterProp,
onMouseLeave: onMouseLeaveProp,
onMouseMove: onMouseMoveProp,
onPointerDown: onPointerDownProp,
onPointerUp: onPointerUpProp,
expandOnHover = false,
asChild,
...rootProps
} = props;
const [isExpanded, setIsExpanded] = React.useState(false);
const [isInteracting, setIsInteracting] = React.useState(false);
const [dimensions, setDimensions] = React.useState<ItemDimension[]>([]);
const childrenArray = React.Children.toArray(children).filter(
React.isValidElement,
);
const childrenCount = childrenArray.length;
const effectiveExpandedItemCount = expandedItemCount ?? childrenCount;
const onMouseEnter = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
onMouseEnterProp?.(event);
if (event.defaultPrevented) return;
if (expandOnHover) {
setIsExpanded(true);
}
},
[expandOnHover, onMouseEnterProp],
);
const onMouseMove = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
onMouseMoveProp?.(event);
if (event.defaultPrevented) return;
if (expandOnHover) {
setIsExpanded(true);
}
},
[expandOnHover, onMouseMoveProp],
);
const onMouseLeave = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
onMouseLeaveProp?.(event);
if (event.defaultPrevented) return;
if (expandOnHover && !isInteracting) {
setIsExpanded(false);
}
},
[expandOnHover, isInteracting, onMouseLeaveProp],
);
const onPointerDown = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
onPointerDownProp?.(event);
if (event.defaultPrevented) return;
setIsInteracting(true);
},
[onPointerDownProp],
);
const onPointerUp = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
onPointerUpProp?.(event);
if (event.defaultPrevented) return;
setIsInteracting(false);
},
[onPointerUpProp],
);
const contextValue = React.useMemo<StackContextValue>(
() => ({
side,
childrenCount,
itemCount,
expandedItemCount: effectiveExpandedItemCount,
gap,
scale,
offset,
expandOnHover,
isExpanded,
isInteracting,
dimensions,
setDimensions,
}),
[
side,
childrenCount,
itemCount,
effectiveExpandedItemCount,
gap,
scale,
offset,
expandOnHover,
isExpanded,
isInteracting,
dimensions,
],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<StackContext.Provider value={contextValue}>
<RootPrimitive
data-slot="stack"
data-state={getDataState(isExpanded)}
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
{...rootProps}
className={cn("relative w-full", className)}
style={
{
"--gap": `${gap}px`,
"--offset": `${offset}px`,
"--scale": scale,
...style,
} as React.CSSProperties
}
>
{childrenArray.map((child, index) => (
<StackItemWrapper key={index} index={index}>
{child}
</StackItemWrapper>
))}
</RootPrimitive>
</StackContext.Provider>
);
}
const stackItemWrapperVariants = cva(
"absolute w-full transition-all duration-300 ease-out",
{
variants: {
side: {
top: [
"top-0 left-0 origin-top",
"translate-y-[calc(var(--translate)*-1)] scale-[var(--item-scale)]",
"after:absolute after:top-full after:left-0 after:w-full after:content-['']",
],
bottom: [
"bottom-0 left-0 origin-bottom",
"translate-y-[var(--translate)] scale-[var(--item-scale)]",
"after:absolute after:bottom-full after:left-0 after:w-full after:content-['']",
],
},
isExpanded: {
true: "after:h-[calc(var(--gap)+1px)]",
false: "",
},
isVisible: {
true: "",
false: "pointer-events-none",
},
},
},
);
type StackItemWrapperElement = React.ComponentRef<typeof StackItemWrapper>;
interface StackItemWrapperProps extends React.ComponentProps<"div"> {
index: number;
}
function StackItemWrapper(props: StackItemWrapperProps) {
const { children, className, index, style, ...itemProps } = props;
const {
side,
childrenCount,
itemCount,
expandedItemCount,
gap,
scale,
offset,
isExpanded,
dimensions,
setDimensions,
} = useStackContext("StackItemWrapper");
const itemRef = React.useRef<StackItemWrapperElement>(null);
const isFront = index === 0;
const isVisible = isExpanded ? index < expandedItemCount : index < itemCount;
React.useEffect(() => {
const itemNode = itemRef.current;
if (itemNode) {
const rect = itemNode.getBoundingClientRect();
const measuredHeight = rect.height;
const currentScale = 1 - index * scale;
const naturalHeight = measuredHeight / currentScale;
setDimensions((d) => {
const existing = d.find((item) => item.itemId === index);
if (!existing) {
return [...d, { itemId: index, size: naturalHeight }];
}
return d;
});
}
}, [index, scale, setDimensions]);
const itemsSizeBefore = React.useMemo(() => {
return dimensions.reduce((prev, curr) => {
if (curr.itemId >= index) return prev;
return prev + curr.size;
}, 0);
}, [dimensions, index]);
const itemScale = isExpanded ? 1 : 1 - index * scale;
const translateValue = isExpanded
? index * gap + itemsSizeBefore
: index * offset;
const zIndex = childrenCount - index;
const opacity = !isVisible ? 0 : isExpanded ? 1 : 1 - index * 0.15;
return (
<div
ref={itemRef}
data-slot="stack-item-wrapper"
data-index={index}
data-front={isFront}
data-visible={isVisible}
data-expanded={isExpanded}
className={cn(
stackItemWrapperVariants({ side, isExpanded, isVisible, className }),
)}
style={
{
"--translate": `${translateValue}px`,
"--item-scale": itemScale,
zIndex,
opacity,
...style,
} as React.CSSProperties
}
{...itemProps}
>
<Slot
data-index={index}
data-position={isFront ? "front" : "back"}
data-state={getDataState(isExpanded)}
>
{children}
</Slot>
</div>
);
}
interface StackItemProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
function StackItem(props: StackItemProps) {
const { asChild, className, ...itemProps } = props;
const ItemPrimitive = asChild ? Slot : "div";
return (
<ItemPrimitive
data-slot="stack-item"
{...itemProps}
className={cn(
"rounded-lg border bg-card p-4 shadow-sm transition-shadow duration-200 hover:shadow-md",
className,
)}
/>
);
}
export {
Stack,
StackItem,
//
type StackProps,
};Layout
import * as Stack from "@/components/ui/stack";
<Stack.Root>
<Stack.Item />
</Stack.Root>Examples
Without Expansion
Disable the hover expansion effect for a static stack.
import { Stack, StackItem } from "@/components/ui/stack";
export function StackNoExpandDemo() {
return (
<div className="flex min-h-[400px] items-center justify-center">
<Stack expandOnHover={false} className="w-[360px]">
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Static Stack</h3>
<p className="text-muted-foreground text-sm">
This stack doesn't expand on hover
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 2</h3>
<p className="text-muted-foreground text-sm">
The stacking effect remains constant
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 3</h3>
<p className="text-muted-foreground text-sm">
Perfect for permanent visual hierarchy
</p>
</StackItem>
</Stack>
</div>
);
}Different Sides
Stack items from different sides using the side prop.
import { Stack, StackItem } from "@/components/ui/stack";
export function StackSideDemo() {
return (
<div className="grid grid-cols-2 gap-8">
<Stack className="w-[300px]" expandOnHover side="top">
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Top Stack</h3>
<p className="text-muted-foreground text-sm">
Items stack toward the top
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 2</h3>
<p className="text-muted-foreground text-sm">Behind the first</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 3</h3>
<p className="text-muted-foreground text-sm">Behind the second</p>
</StackItem>
</Stack>
<Stack className="w-[300px]" expandOnHover side="bottom">
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Bottom Stack</h3>
<p className="text-muted-foreground text-sm">
Items stack toward the bottom
</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 2</h3>
<p className="text-muted-foreground text-sm">Behind the first</p>
</StackItem>
<StackItem className="flex flex-col gap-2">
<h3 className="font-semibold">Item 3</h3>
<p className="text-muted-foreground text-sm">Behind the second</p>
</StackItem>
</Stack>
</div>
);
}API Reference
Stack.Root
The main container for the stack component that handles layout and hover interactions.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-expanded] | "true" | "false" |
Stack.Item
Individual items within the stack. These are automatically positioned and animated.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-index] | "number" |
[data-front] | "true" | "false" |
[data-visible] | "true" | "false" |
Features
- Hover Expansion: Items expand on hover to reveal all stacked items
- Customizable: Control visible items, gap, offset, and scale factor
- Smooth Animations: Elegant CSS transitions for all interactions
- Flexible Styling: Works with any content and styling approach
- Accessibility: Proper data attributes and semantic HTML
Usage Notes
- The stack uses absolute positioning for items, ensure the parent container has enough space
- Use the
visibleItemsprop to control how many items are visible in the collapsed state - The
scaleFactorprop determines how much each subsequent item shrinks (0.05 = 5% smaller) - Set
expandOnHover={false}to disable the expansion effect - All items beyond
visibleItemswill have reduced opacity and be non-interactive when collapsed
Credits
- Emil Kowalski - For the Sonner toast library.