makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
items*{ id; name; src?; alt?; screen? }[]Screens in order. Use src for an image or screen for your own content.
defaultIndexnumberScreen in front when nothing is hovered. Defaults to the middle.
onSelect(item) => voidCalled when a screen is clicked.
classNamestringExtra 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>
  )
}

On this page

Image3