Screen Stack
A fan of screens where the one you hover springs to the front.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/screen-stack.jsonDependencies
- motion
Interaction
Hover or focus a screen and it comes forward while the others tuck behind to either side. The caption below swaps with a soft blur, and the stack settles back to the middle when you leave.
Props
| Prop | Type | Description |
|---|---|---|
| items* | { id; name; src?; alt?; screen? }[] | Screens in order. Use src for an image or screen for your own content. |
| defaultIndex | number | Screen in front when nothing is hovered. Defaults to the middle. |
| onSelect | (item) => void | Called when a screen is clicked. |
| className | string | Extra classes merged onto the root element. |
How to use
import ScreenStack from "@/components/ui/screen-stack"
export function Example() {
return <ScreenStack items={[{ id: "a", name: "Home", src: "/home.png" }]} />
}Source code
components/ui/screen-stack.tsx
"use client"
import { useState, type ReactNode } from "react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface ScreenItem {
id: string
name: string
src?: string
alt?: string
screen?: ReactNode
}
export interface ScreenStackProps {
items: ScreenItem[]
defaultIndex?: number
onSelect?: (item: ScreenItem) => void
className?: string
}
const spring = { type: "spring", stiffness: 220, damping: 26 } as const
export default function ScreenStack({ items, defaultIndex, onSelect, className }: ScreenStackProps) {
const home = defaultIndex ?? Math.floor((items.length - 1) / 2)
const [active, setActive] = useState(home)
const pose = (i: number) => {
const k = i - active
if (k === 0) return { x: "0%", scale: 1, rotateZ: 0, rotateY: 0, veil: 0 }
const side = Math.sign(k)
const depth = Math.min(Math.abs(k) - 1, 1)
return {
x: `${side * (52 + depth * 12)}%`,
scale: 0.88 - depth * 0.04,
rotateZ: side * (2 + depth * 1.5),
rotateY: -side * 12,
veil: 0.4 + depth * 0.12,
}
}
return (
<div className={cn("w-full", className)} onPointerLeave={() => setActive(home)}>
<div role="group" aria-label="Screens" className="relative mx-auto aspect-[2.3/1] w-full [perspective:1200px]">
{items.map((item, i) => {
const p = pose(i)
return (
<motion.button
key={item.id}
type="button"
aria-label={item.name}
aria-current={i === active}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => onSelect?.(item)}
initial={false}
animate={{ x: p.x, y: "-50%", scale: p.scale, rotateZ: p.rotateZ, rotateY: p.rotateY }}
transition={spring}
style={{ zIndex: items.length - Math.abs(i - active), left: "27%" }}
className={cn(
"absolute top-1/2 aspect-[4/3] w-[46%] rounded-[1.1rem] p-[4px] text-left outline-none",
"bg-neutral-200 ring-1 ring-black/10 shadow-[0_18px_30px_-14px_rgba(0,0,0,0.3)]",
"dark:bg-neutral-800 dark:ring-white/10 dark:shadow-[0_22px_36px_-14px_rgba(0,0,0,0.7)]",
"focus-visible:ring-2 focus-visible:ring-sky-500",
)}
>
<span className="relative block size-full overflow-hidden rounded-[0.8rem] bg-white dark:bg-neutral-900">
{item.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.src} alt={item.alt ?? ""} className="size-full object-cover" draggable={false} />
) : (
item.screen
)}
{/* theme-aware veil: washes out in light mode, darkens in dark mode */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: p.veil }}
transition={spring}
className="pointer-events-none absolute inset-0 bg-bg"
/>
</span>
</motion.button>
)
})}
</div>
<div className="mt-4 h-6 overflow-hidden text-center text-sm font-medium" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={items[active]?.id}
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -8, filter: "blur(4px)" }}
transition={{ duration: 0.16 }}
>
{items[active]?.name}
</motion.p>
</AnimatePresence>
</div>
</div>
)
}