# Isometric Stack (`isometric-stack`)

> An exploded isometric layer diagram for architecture and platform sections — extruded plates hover apart on staggered springs, and a leader-line label ignites beside each layer.

- **Docs:** https://www.mellowui.com/components/isometric-stack
- **Markdown:** https://www.mellowui.com/components/isometric-stack.md
- **Registry:** https://www.mellowui.com/r/isometric-stack.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/isometric-stack
- **Categories:** display, 3d, interactive, animation
- **Dependencies:** motion

## AI prompt

Add an IsometricStack component from the mellow library — an exploded isometric layer diagram. Pass `layers` (label, optional sublabel, optional accent) top of stack first. Hover (or focus) explodes it on cascading springs with a leader-line label per layer; click pins it open. Tune `gap`, `stiffness` and `damping`. `defaultExpanded` starts it pinned (also the reduced-motion state). Good for architecture / platform sections.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/isometric-stack.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `layers` | `IsometricStackLayer[]` | — | Layers, top of the stack first — label, optional sublabel, optional accent. |
| `size` | `number` | `380` | Rendered width in px. |
| `defaultExpanded` | `boolean` | `false` | Start pinned open (also the reduced-motion state). |
| `gap` | `number` | `44` | Vertical gap between plates when expanded, in px. |
| `stiffness` | `number` | `240` | Base explode-spring stiffness (deeper plates get a softer spring). |
| `damping` | `number` | `26` | Explode-spring damping. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import { useReducedMotion, useSpring } from "motion/react";

export interface IsometricStackLayer {
  label: string;
  sublabel?: string;
  /** Top-face wash. Any CSS color; defaults to an alternating ink tint. */
  accent?: string;
}

export interface IsometricStackProps {
  /** Layers, top of the stack first. */
  layers: IsometricStackLayer[];
  /** Rendered width in px. */
  size?: number;
  /** Start pinned open (also the reduced-motion state). */
  defaultExpanded?: boolean;
  /** Vertical gap between plates when expanded, in px. */
  gap?: number;
  /** Base explode-spring stiffness (deeper plates get a softer spring). */
  stiffness?: number;
  /** Explode-spring damping. */
  damping?: number;
  className?: string;
  style?: React.CSSProperties;
}

// world X -> (0.866, 0.5), world Y -> (-0.866, 0.5), world Z -> screen -Y
const ISO = "matrix(0.866, 0.5, -0.866, 0.5, 0, 0)";
const HW = 92;
const HH = 64;
const R = 18;
const DEPTH = 12;
const GAP_C = 8;
const STEP = 2;

const ROUNDED_RECT = `M ${-HW + R} ${-HH} L ${HW - R} ${-HH} A ${R} ${R} 0 0 1 ${HW} ${
  -HH + R
} L ${HW} ${HH - R} A ${R} ${R} 0 0 1 ${HW - R} ${HH} L ${-HW + R} ${HH} A ${R} ${R} 0 0 1 ${-HW} ${
  HH - R
} L ${-HW} ${-HH + R} A ${R} ${R} 0 0 1 ${-HW + R} ${-HH} Z`;

const FACE = "color-mix(in oklab, var(--ink) 5%, var(--background))";
const PAINT = "transition-[fill,stroke] duration-300";

const DEPTH_STEPS: number[] = [];
for (let z = 0; z <= DEPTH; z += STEP) DEPTH_STEPS.push(z);

/**
 * An exploded isometric layer diagram for architecture and platform sections —
 * extruded plates hover apart on cascading springs, and a leader-line label
 * ignites beside each layer.
 */
export function IsometricStack({
  layers,
  size = 380,
  defaultExpanded = false,
  gap = 44,
  stiffness = 240,
  damping = 26,
  className,
  style,
}: IsometricStackProps) {
  const [pinned, setPinned] = useState(defaultExpanded);
  const [hovered, setHovered] = useState(false);
  const rootRef = useRef<HTMLButtonElement>(null);
  const reduced = useReducedMotion();
  const expanded = !!reduced || pinned || hovered;
  const n = layers.length;

  // pointerleave can miss (scroll-away, drag-out). If we're still marked
  // hovered but the pointer is outside our box, clear it so labels don't stick.
  useEffect(() => {
    if (!hovered) return;
    const clear = () => setHovered(false);
    const onMove = (e: PointerEvent) => {
      const root = rootRef.current;
      if (!root) return;
      const r = root.getBoundingClientRect();
      if (
        e.clientX < r.left ||
        e.clientX > r.right ||
        e.clientY < r.top ||
        e.clientY > r.bottom
      ) {
        clear();
      }
    };
    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("blur", clear);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("blur", clear);
    };
  }, [hovered]);

  return (
    <button
      ref={rootRef}
      type="button"
      aria-expanded={expanded}
      aria-label={`Layer stack: ${layers.map((l) => l.label).join(", ")}`}
      onClick={() => setPinned((p) => !p)}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={() => setHovered(false)}
      onPointerCancel={() => setHovered(false)}
      className={[
        "inline-block cursor-pointer rounded-xl bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.3)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={style}
    >
      <svg
        viewBox="-145 -215 460 360"
        width={size}
        height={(size * 360) / 460}
        aria-hidden="true"
        className="block"
      >
        {[...layers.keys()].reverse().map((i) => (
          <Plate
            key={i}
            i={i}
            n={n}
            layer={layers[i]}
            expanded={expanded}
            gap={gap}
            stiffness={stiffness}
            damping={damping}
            reduced={!!reduced}
          />
        ))}
      </svg>
    </button>
  );
}

function Plate({
  i,
  n,
  layer,
  expanded,
  gap,
  stiffness,
  damping,
  reduced,
}: {
  i: number;
  n: number;
  layer: IsometricStackLayer;
  expanded: boolean;
  gap: number;
  stiffness: number;
  damping: number;
  reduced: boolean;
}) {
  const zBase = (n - 1 - i) * (DEPTH + (expanded ? gap : GAP_C));
  // expand from the middle: the whole stack drops by half the added height
  const shift = expanded ? ((n - 1) * (gap - GAP_C)) / 2 : 0;
  const target = shift - zBase;

  const ref = useRef<SVGGElement>(null);

  // deeper plates get a softer spring, so the stack opens as a cascade.
  // motion can't drive transforms on SVG <g>, so write the attribute directly.
  const y = useSpring(target, {
    stiffness: Math.max(20, stiffness - i * 30),
    damping,
  });
  useEffect(() => {
    const apply = (v: number) =>
      ref.current?.setAttribute("transform", `translate(0 ${v})`);
    apply(y.get());
    return y.on("change", apply);
  }, [y]);
  useEffect(() => {
    if (reduced) y.jump(target);
    else y.set(target);
  }, [target, reduced, y]);

  const accent =
    layer.accent ??
    (i % 2 === 0
      ? "color-mix(in oklab, var(--ink) 10%, var(--background))"
      : FACE);

  return (
    <g ref={ref}>
      {/* extrusion, bottom to top */}
      {DEPTH_STEPS.map((z) => (
        <g key={z} transform={`translate(0, ${-z}) ${ISO}`}>
          <path
            d={ROUNDED_RECT}
            fill={z === DEPTH ? accent : FACE}
            stroke="var(--ink)"
            strokeWidth={z === 0 || z === DEPTH ? 1.6 : 0.75}
            strokeOpacity={z === 0 || z === DEPTH ? 0.9 : 0.5}
            vectorEffect="non-scaling-stroke"
            className={PAINT}
          />
        </g>
      ))}

      {/* leader line + label — CSS opacity, not motion.g: exit has no delay
          so labels can't linger (and overlap) while plates spring shut. */}
      <g
        style={{
          opacity: expanded ? 1 : 0,
          visibility: expanded ? "visible" : "hidden",
          transition: reduced
            ? undefined
            : expanded
              ? `opacity 0.25s ease ${0.05 + i * 0.05}s`
              : "opacity 0.12s ease",
        }}
      >
        <circle cx={86} cy={40} r={2.5} fill="var(--ink)" />
        <line
          x1={90}
          y1={40}
          x2={138}
          y2={40}
          stroke="var(--ink)"
          strokeOpacity={0.35}
          strokeWidth={1}
        />
        <text
          x={146}
          y={40}
          dominantBaseline="middle"
          fill="var(--ink)"
          style={{
            fontFamily: "var(--font-mono)",
            fontSize: 11,
            letterSpacing: "0.14em",
            textTransform: "uppercase",
          }}
        >
          {layer.label}
        </text>
        {layer.sublabel && (
          <text
            x={146}
            y={56}
            dominantBaseline="middle"
            fill="var(--ink)"
            fillOpacity={0.5}
            style={{ fontFamily: "var(--font-sans)", fontSize: 11 }}
          >
            {layer.sublabel}
          </text>
        )}
      </g>
    </g>
  );
}

export default IsometricStack;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { IsometricStack } from "../mellow/isometric-stack";

export const ISOMETRIC_STACK_LAYERS = [
  {
    label: "Interface",
    sublabel: "React components & pages",
    accent: "color-mix(in oklab, oklch(0.65 0.25 250) 22%, var(--background))",
  },
  {
    label: "Motion",
    sublabel: "Springs, gestures, physics",
  },
  {
    label: "Tokens",
    sublabel: "Theme engine, light & dark",
  },
  {
    label: "Registry",
    sublabel: "Copy-paste source of truth",
  },
];


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function IsometricStackDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex flex-col items-center gap-2 p-3 sm:p-6">
      <IsometricStack layers={ISOMETRIC_STACK_LAYERS} size={narrow ? 200 : 400} />
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Hover to explode the stack · click to pin it open
      </p>
    </div>
  );
}

```
