# Layer Stack

> Isometric stack of five layers with a list beside it – the chosen entry lifts its layer out in color

A shadcn/ui block from the [@affectui](https://www.affectui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/affectui/layer-stack).

- Page, with a live preview: https://shadcn.exchange/r/affectui/layer-stack
- Screenshot: https://shadcn.exchange/shot/affectui/layer-stack/light.png
- Registry item: https://www.affectui.com/r/layer-stack.json
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/affectui/layer-stack.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `illustrations/layer-stack/layer-stack.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, type ReactNode } from "react";

/*
 * Layer Stack – an interactive card illustration, in a single self-contained file.
 * An isometric stack of five layers next to a list of five entries. The chosen entry lifts its layer
 * out of the stack in blue, with a soft glow underneath; the others settle back to glassy gray.
 * It steps through the entries on its own and pauses while you hover; click or focus an entry to pick it.
 * The artwork is laid out on a 600 × 400 grid inside a 3 : 2 frame and scales with its container.
 */

export type LayerStackItem = {
  /** Main line of the entry */
  title: string;
  /** Short muted hint on the right */
  hint?: string;
  /** Icon drawn onto the layer: one of the built-in names */
  icon?: LayerIcon;
};

export type LayerIcon = "bolt" | "database" | "plug" | "spark" | "pulse";

export type LayerStackProps = {
  /** Up to five entries, top layer first */
  items?: LayerStackItem[];
  /** Entry to start on */
  defaultIndex?: number;
  /** Time per entry while it plays on its own, in ms */
  interval?: number;
  /** Called when an entry is picked by the user */
  onChange?: (index: number) => void;
  className?: string;
};

const defaultItems: LayerStackItem[] = [
  { title: "Triggered by events", hint: "Webhooks", icon: "bolt" },
  { title: "Grounded in your data", hint: "Context", icon: "database" },
  { title: "Connected to your apps", hint: "APIs", icon: "plug" },
  { title: "Works with any model", hint: "Routing", icon: "spark" },
  { title: "Traced at every step", hint: "Logs", icon: "pulse" },
];

/* Layer geometry on the 600 × 400 grid */
const SIZE = 104; // side of a layer before the isometric projection (rhombus 208 × 104)
const STEP = 26; // vertical distance between resting layers
const GAP = 34; // the chosen layer lifts off the ones below it
const ABOVE = 24; // and the layers above it lift a little further, so it shows
const THICK = 9; // layer thickness
const CX = 168; // stack centre
const TOP = 109; // y of the top layer's back corner when nothing is lifted

export function LayerStack({ items = defaultItems, defaultIndex = 0, interval = 2600, onChange, className }: LayerStackProps) {
  const id = "ui-lyr" + useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const list = items.slice(0, 5);
  const [active, setActive] = useState(Math.min(defaultIndex, list.length - 1));
  const [paused, setPaused] = useState(false);
  const touched = useRef(false);

  // Step through the entries on its own until someone interacts; never with reduced motion
  useEffect(() => {
    if (paused || touched.current || list.length < 2) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const timer = window.setInterval(() => setActive((i) => (i + 1) % list.length), interval);
    return () => window.clearInterval(timer);
  }, [paused, interval, list.length]);

  const pick = (i: number) => {
    touched.current = true;
    setActive(i);
    onChange?.(i);
  };

  // Resting y of each layer: gaps open below and above the chosen one. The bottom layer has nothing
  // below it, so when it's chosen it stays put and only the layers above it rise
  const last = list.length - 1;
  const yOf = (i: number) => TOP + i * STEP - (i < active ? GAP + ABOVE : i === active && active < last ? GAP : 0);

  return (
    <div
      className={["ui-lyr", className].filter(Boolean).join(" ")}
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
    >
      {/* React 19 hoists this into <head> once, no matter how many instances render */}
      <style href="ui-layer-stack" precedence="default">
        {css}
      </style>

      <svg className="ui-lyr-stack" viewBox="0 0 600 400" aria-hidden="true">
        <defs>
          <filter id={`${id}-glow`} x="-60%" y="-120%" width="220%" height="340%">
            <feGaussianBlur stdDeviation="14" />
          </filter>
        </defs>

        {/* Bottom layer first, so the upper ones overlap it */}
        {list
          .map((item, i) => (
            <g
              key={i}
              className="ui-lyr-layer"
              data-active={i === active ? "" : undefined}
              data-above={i < active ? "" : undefined}
              style={{ transform: `translate(${CX}px, ${yOf(i)}px)` }}
            >
              {/* Glow just beneath this layer – fades in while it's the chosen one, same strength on every layer */}
              <ellipse className="ui-lyr-glow" cx={0} cy={SIZE / 2 + THICK + 18} rx={70} ry={24} filter={`url(#${id}-glow)`} />
              {/* Thickness: the same rhombus, shifted down, peeks out below the top face */}
              <g transform={`translate(0 ${THICK}) matrix(1 0.5 -1 0.5 0 0)`}>
                <rect className="ui-lyr-side" width={SIZE} height={SIZE} rx={9} />
                <rect className="ui-lyr-side-on" width={SIZE} height={SIZE} rx={9} />
              </g>
              <g transform="matrix(1 0.5 -1 0.5 0 0)">
                <rect className="ui-lyr-face" width={SIZE} height={SIZE} rx={9} />
                <rect className="ui-lyr-face-on" width={SIZE} height={SIZE} rx={9} />
                <g className="ui-lyr-icon" transform={`translate(${SIZE / 2 - 18} ${SIZE / 2 - 18}) scale(1.5)`}>
                  {icons[item.icon ?? "bolt"]}
                </g>
              </g>
            </g>
          ))
          .reverse()}
      </svg>

      <div className="ui-lyr-menu" role="tablist" aria-label="Layers" aria-orientation="vertical">
        <span className="ui-lyr-pill" aria-hidden="true" style={{ transform: `translateY(calc(${active} * var(--ui-lyr-row)))` }} />
        {list.map((item, i) => (
          <button
            key={i}
            type="button"
            role="tab"
            aria-selected={i === active}
            tabIndex={i === active ? 0 : -1}
            className="ui-lyr-row"
            onClick={() => pick(i)}
            onKeyDown={(e) => {
              const next = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : null;
              if (next === null) return;
              e.preventDefault();
              const n = (next + list.length) % list.length;
              pick(n);
              (e.currentTarget.parentElement?.children[n + 1] as HTMLElement | undefined)?.focus();
            }}
          >
            <span className="ui-lyr-title">{item.title}</span>
            {item.hint && <span className="ui-lyr-hint">{item.hint}</span>}
          </button>
        ))}
      </div>
    </div>
  );
}

/* ---------- Icons, drawn on a 24 grid and laid onto the layer ---------- */

const icons: Record<LayerIcon, ReactNode> = {
  bolt: <path d="M13 3 5 13.5h6L10 21l8-10.5h-6L13 3Z" />,
  database: (
    <>
      <ellipse cx="12" cy="6" rx="7" ry="3" />
      <path d="M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3" />
    </>
  ),
  plug: <path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0V8ZM12 16.5V21" />,
  spark: <path d="M12 3c.6 4.6 2.4 6.4 7 7-4.6.6-6.4 2.4-7 7-.6-4.6-2.4-6.4-7-7 4.6-.6 6.4-2.4 7-7ZM18.5 15.5c.2 1.5.8 2.1 2.3 2.3-1.5.2-2.1.8-2.3 2.3-.2-1.5-.8-2.1-2.3-2.3 1.5-.2 2.1-.8 2.3-2.3Z" />,
  pulse: <path d="M3 12h4l2.5-6 5 12 2.5-6h4" />,
};

/* ---------- Styles ---------- */

const css = /* css */ `
.ui-lyr {
  /* 1 unit of the 600 × 400 layout grid */
  --u: calc(100cqw / 600);
  --ui-lyr-row: calc(52 * var(--u));
  /* The accent blue used across the library */
  --ui-lyr-accent: #2f6bff;
  --ui-lyr-accent-deep: #2557d9;
  --ui-lyr-tint: #e9f0ff;
  --ui-lyr-glow: rgba(47, 107, 255, 0.28);
  --ui-lyr-face: rgba(255, 255, 255, 0.6);
  --ui-lyr-edge: rgba(10, 10, 10, 0.11);
  --ui-lyr-side: #ececef;
  --ui-lyr-face-above: rgba(255, 255, 255, 0.3);
  --ui-lyr-side-above: rgba(236, 236, 239, 0.55);
  --ui-lyr-panel: #f5f5f5;
  --ui-lyr-pill: #ffffff;
  --ui-lyr-pill-ring: #e5e5e5;
  --ui-lyr-text: #171717;
  --ui-lyr-muted: #8f8f8f;
  --ui-lyr-ease: cubic-bezier(0.65, 0, 0.25, 1);
  position: relative;
  width: 100%;
  aspect-ratio: 3 / 2;
  container-type: inline-size;
  font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
  -webkit-font-smoothing: antialiased;
}
.ui-lyr *, .ui-lyr *::before, .ui-lyr *::after { box-sizing: border-box; }

/* ---------- Stack ---------- */

.ui-lyr-stack { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.ui-lyr-layer { transition: transform 560ms var(--ui-lyr-ease); }
.ui-lyr-glow {
  fill: var(--ui-lyr-glow);
  opacity: 0;
  transition: opacity 420ms ease;
}
.ui-lyr-layer[data-active] .ui-lyr-glow { opacity: 1; }
.ui-lyr-side-on {
  fill: var(--ui-lyr-accent-deep);
  opacity: 0;
  transition: opacity 360ms ease;
}
.ui-lyr-side {
  fill: var(--ui-lyr-side);
  stroke: var(--ui-lyr-edge);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  transition: fill 360ms ease, stroke 360ms ease;
}
.ui-lyr-face {
  fill: var(--ui-lyr-face);
  stroke: var(--ui-lyr-edge);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  transition: fill 360ms ease, stroke 360ms ease;
}
.ui-lyr-face-on {
  fill: var(--ui-lyr-tint);
  stroke: var(--ui-lyr-accent);
  opacity: 0;
  stroke-width: 1.6;
  vector-effect: non-scaling-stroke;
  transition: opacity 360ms ease;
}
.ui-lyr-icon {
  fill: none;
  stroke: var(--ui-lyr-accent);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transition: opacity 300ms ease;
}
/* Layers above the chosen one turn more glassy, so its color shows through */
.ui-lyr-layer[data-above] .ui-lyr-face { fill: var(--ui-lyr-face-above); }
.ui-lyr-layer[data-above] .ui-lyr-side { fill: var(--ui-lyr-side-above); }
.ui-lyr-layer[data-active] .ui-lyr-side-on { opacity: 1; }
.ui-lyr-layer[data-active] .ui-lyr-face-on { opacity: 1; }
.ui-lyr-layer[data-active] .ui-lyr-icon { opacity: 1; transition-delay: 120ms; }

/* ---------- Menu ---------- */

.ui-lyr-menu {
  position: absolute;
  left: calc(326 * var(--u));
  top: 50%;
  width: calc(248 * var(--u));
  padding: calc(8 * var(--u));
  transform: translateY(-50%);
  border-radius: calc(18 * var(--u));
  background: var(--ui-lyr-panel);
}
/* White card that glides behind the chosen row */
.ui-lyr-pill {
  position: absolute;
  left: calc(8 * var(--u));
  right: calc(8 * var(--u));
  top: calc(8 * var(--u));
  height: var(--ui-lyr-row);
  border-radius: calc(12 * var(--u));
  background: var(--ui-lyr-pill);
  box-shadow: 0 0 0 1px var(--ui-lyr-pill-ring), 0 1px 2px rgba(0, 0, 0, 0.05);
  transition: transform 460ms var(--ui-lyr-ease);
}
.ui-lyr-row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(10 * var(--u));
  width: 100%;
  height: var(--ui-lyr-row);
  margin: 0;
  padding: 0 calc(14 * var(--u));
  border: 0;
  border-radius: calc(12 * var(--u));
  background: transparent;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.ui-lyr-row:focus-visible { outline: 2px solid var(--ui-lyr-accent); outline-offset: -2px; }
.ui-lyr-title {
  min-width: 0;
  overflow: hidden;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--ui-lyr-text);
  transition: color 300ms ease;
}
.ui-lyr-hint {
  flex: none;
  font-size: calc(12 * var(--u));
  line-height: 1.3;
  color: var(--ui-lyr-muted);
}
.ui-lyr-row[aria-selected="true"] .ui-lyr-title { color: var(--ui-lyr-accent); }

@media (prefers-reduced-motion: reduce) {
  .ui-lyr-layer, .ui-lyr-glow, .ui-lyr-pill { transition: none; }
}

/* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. <html> */
:where(.dark, [data-theme="dark"]) .ui-lyr {
  --ui-lyr-accent: #6b95ff;
  --ui-lyr-accent-deep: #2f6bff;
  --ui-lyr-tint: #1f2c4d;
  --ui-lyr-glow: rgba(47, 107, 255, 0.3);
  --ui-lyr-face: rgba(255, 255, 255, 0.04);
  --ui-lyr-edge: rgba(255, 255, 255, 0.13);
  --ui-lyr-side: #26262a;
  --ui-lyr-face-above: rgba(255, 255, 255, 0.025);
  --ui-lyr-side-above: rgba(38, 38, 42, 0.5);
  --ui-lyr-panel: #161618;
  --ui-lyr-pill: #232326;
  --ui-lyr-pill-ring: rgba(255, 255, 255, 0.08);
  --ui-lyr-text: #ededed;
  --ui-lyr-muted: #8f8f8f;
}
`;
```
