Skip to content
UI Kit
Accent color

Isometric illustration stage

A product or system drawn as plates in an isometric room, lifted apart into its layers. A legend beside it lifts one plate on hover or focus, a button puts the stage together and takes it apart.

4 variantsOne of 6 components in 3D surfaces

A product taken apart in layers

An isometric stage explains how something is built by lifting it apart. The plates stand in an isometric room, a legend beside them lifts one plate on hover or focus, and a button puts the stage together and takes it apart again, so the explanation works with mouse, keyboard and touch.

Dashboard taken apart lifts a dashboard into window, navigation, figures, chart and a note as it scrolls into view. System stack stacks four equal plates like floors, from the interface down to the infrastructure. Page in sections lifts the sections of a website from navigation to footer, good for a site builder or an agency. Workflow on a floor stands four steps on a floor, joined by a path a signal runs along.

Name every layer in the legend with what it does for the customer. Four or five plates read best. Use the stage once per page, where the structure itself is the selling point.

Variants

Dashboard taken apartNew in 1.2

A dashboard in five layers, window, navigation, figures, chart and a note, lifted apart as it scrolls into view.

System stackNew in 1.2

Four equal plates stacked like floors, a system from the interface down to its infrastructure. The legend reads from top to bottom.

Page in sectionsNew in 1.2

The sections of a website, from navigation to footer, lifted apart. Put together they form one page, good for a site builder or an agency.

Workflow on a floorNew in 1.2

Four steps standing on a floor, joined by a path a signal runs along while the stage is on screen. Pointing at a step lifts its block.

Details

What Isometric stage brings along, read from the registry and the source.

Category
3D surfaces
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/isometric-stage.tsx
Brings along
utilskitsectioniconrevealdepth
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

Props

The IsometricStageProps interface from components/ui/isometric-stage.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.

  • variantoptional
    IsometricStageVariant

    dashboardstackpageworkflow

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsrequired
    IsoItem[]

    One entry per layer, in order from the bottom up. Extra entries are ignored.

  • reverseoptional
    boolean

    Stage on the left, legend on the right. Default false.

Built-in texts

Isometric stage writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
explodeEnglish Take apartGerman Auseinandernehmen
assembleEnglish Put togetherGerman Zusammensetzen
layersEnglish LayersGerman Ebenen

Accessibility

Read from the source of the component.

  • Sets aria-label and aria-pressed, so screen readers announce purpose and state.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 268 lines
components/ui/isometric-stage.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Slab, ease3d, useActive } from '@/components/ui/depth';

/*
 * An isometric stage: a product or a system drawn as plates in an isometric room, lifted apart
 * into its layers. Next to it stands a legend; pointing at or focusing an entry lifts its plate
 * and dims the others, a button puts the stage together and takes it apart again.
 *
 * The plates are drawn shapes in the theme colours, they carry meaning only through the legend,
 * so the stage itself is a picture (aria-hidden) and every word lives in real text beside it.
 *
 * Without JavaScript and with reduced motion the stage shows its layers apart, a finished still
 * picture. With JavaScript it assembles first when it starts below the window and comes apart as
 * it scrolls into view. The signal on the workflow runs only while the stage is on screen.
 *
 *   dashboard  a dashboard in five layers, window, navigation, figures, chart and a note
 *   stack      four equal plates, a system from interface to infrastructure
 *   page       the sections of a website, lifted apart and put together into one page
 *   workflow   steps standing on a floor, joined by paths a signal runs along
 */

export type IsometricStageVariant = 'dashboard' | 'stack' | 'page' | 'workflow';

export interface IsoItem {
  title: string;
  text?: string;
  icon?: IconName;
}

export interface IsometricStageProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: IsometricStageVariant;
  eyebrow?: string;
  title?: React.ReactNode;
  description?: string;
  /** One entry per layer, in order from the bottom up. Extra entries are ignored. */
  items: IsoItem[];
  /** Stage on the left, legend on the right. Default false. */
  reverse?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
}

const TEXTS = {
  en: { explode: 'Take apart', assemble: 'Put together', layers: 'Layers' },
  de: { explode: 'Auseinandernehmen', assemble: 'Zusammensetzen', layers: 'Ebenen' },
};

type Plate = { x: number; y: number; w: number; h: number; z: number; draw: (on: boolean) => React.ReactNode; depth?: number; tone?: 'card' | 'accent' | 'surface' };

const tint = (k: number) => `color-mix(in oklab, var(--primary) ${k}%, var(--card))`;
const ink = (k: number) => `color-mix(in oklab, var(--foreground) ${k}%, transparent)`;

/* ------------------------------------------------------------------ */
/* Drawn faces                                                         */
/* ------------------------------------------------------------------ */

const Bars = ({ n = 5 }: { n?: number }) => (
  <span className="absolute inset-[14%] flex items-end gap-[8%]">
    {Array.from({ length: n }, (_, i) => (
      <span key={i} className="flex-1 rounded-t-[2px]" style={{ height: `${[42, 64, 52, 86, 70, 94][i % 6]}%`, background: i === 3 ? 'var(--primary)' : tint(34) }} />
    ))}
  </span>
);

const Lines = ({ n = 3, accent = false }: { n?: number; accent?: boolean }) => (
  <span className="absolute inset-[16%] flex flex-col justify-center gap-[14%]">
    {Array.from({ length: n }, (_, i) => (
      <span key={i} className="h-[10%] min-h-[3px] rounded-full" style={{ width: `${[78, 56, 66, 40][i % 4]}%`, background: accent && i === 0 ? 'var(--primary)' : ink(i === 0 ? 30 : 14) }} />
    ))}
  </span>
);

const Chart = () => (
  <svg viewBox="0 0 100 60" preserveAspectRatio="none" className="absolute inset-[10%] size-[80%]">
    <path d="M0 52 L14 44 L28 47 L42 32 L56 36 L70 20 L84 24 L100 8 L100 60 L0 60Z" fill={tint(22)} />
    <path d="M0 52 L14 44 L28 47 L42 32 L56 36 L70 20 L84 24 L100 8" fill="none" stroke="var(--primary)" strokeWidth="2.4" vectorEffect="non-scaling-stroke" />
  </svg>
);

const Grid = ({ cols = 3, rows = 2 }: { cols?: number; rows?: number }) => (
  <span className="absolute inset-[10%] grid gap-[6%]" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }}>
    {Array.from({ length: cols * rows }, (_, i) => (
      <span key={i} className="rounded-[3px]" style={{ background: i === 1 ? tint(40) : ink(8) }} />
    ))}
  </span>
);

const Dots = () => (
  <span className="absolute inset-[12%] grid grid-cols-6 place-items-center gap-[6%]">
    {Array.from({ length: 18 }, (_, i) => (
      <span key={i} className="aspect-square w-[46%] rounded-full" style={{ background: i % 5 === 0 ? 'var(--primary)' : ink(18) }} />
    ))}
  </span>
);

/* ------------------------------------------------------------------ */
/* Plates per variant (x, y, w, h in % of the floor; z is the level)    */
/* ------------------------------------------------------------------ */

const PLATES: Record<IsometricStageVariant, Plate[]> = {
  dashboard: [
    { x: 0, y: 0, w: 100, h: 100, z: 0, tone: 'surface', draw: () => <span className="absolute inset-x-0 top-0 h-[8%] border-b border-border" style={{ background: ink(4) }} /> },
    { x: 4, y: 12, w: 20, h: 84, z: 1, draw: () => <Lines n={4} accent /> },
    { x: 28, y: 12, w: 68, h: 22, z: 2, draw: () => <Grid cols={3} rows={1} /> },
    { x: 28, y: 38, w: 68, h: 58, z: 3, draw: () => <Chart /> },
    { x: 60, y: 44, w: 30, h: 18, z: 4.4, tone: 'accent', draw: () => <Lines n={2} /> },
  ],
  stack: [
    { x: 6, y: 6, w: 88, h: 88, z: 0, draw: () => <Grid cols={4} rows={4} /> },
    { x: 6, y: 6, w: 88, h: 88, z: 1.3, draw: () => <Dots /> },
    { x: 6, y: 6, w: 88, h: 88, z: 2.6, draw: () => <Lines n={4} /> },
    { x: 6, y: 6, w: 88, h: 88, z: 3.9, tone: 'accent', draw: () => <Grid cols={3} rows={2} /> },
  ],
  page: [
    { x: 14, y: 76, w: 72, h: 18, z: 0, draw: () => <Grid cols={4} rows={1} /> },
    { x: 14, y: 56, w: 72, h: 18, z: 1, draw: () => <Lines n={2} /> },
    { x: 14, y: 30, w: 72, h: 24, z: 2, draw: () => <Grid cols={3} rows={1} /> },
    { x: 14, y: 12, w: 72, h: 16, z: 3, tone: 'accent', draw: () => <Lines n={2} /> },
    { x: 14, y: 2, w: 72, h: 8, z: 4, draw: () => <Lines n={1} accent /> },
  ],
  workflow: [
    { x: 0, y: 0, w: 100, h: 100, z: 0, depth: 10, tone: 'surface', draw: () => null },
    { x: 8, y: 10, w: 22, h: 22, z: 1, depth: 26, draw: () => <Icon name="inbox" className="absolute inset-[30%] size-[40%] text-primary" /> },
    { x: 70, y: 12, w: 22, h: 22, z: 1, depth: 26, draw: () => <Icon name="sparkle" className="absolute inset-[30%] size-[40%] text-primary" /> },
    { x: 10, y: 68, w: 22, h: 22, z: 1, depth: 26, draw: () => <Icon name="check" className="absolute inset-[30%] size-[40%] text-primary" /> },
    { x: 68, y: 66, w: 24, h: 24, z: 1, depth: 26, tone: 'accent', draw: () => <Icon name="send" className="absolute inset-[30%] size-[40%] text-primary-foreground" /> },
  ],
};

/** The path a signal takes on the workflow floor, in % of the floor */
const FLOW = 'M19 21 L81 23 L21 79 L80 78';

/* ------------------------------------------------------------------ */

/** A system drawn as plates in an isometric room, lifted apart into its layers. */
export function IsometricStage({ variant = 'dashboard', eyebrow, title, description, items, reverse = false, labels, className, ...rest }: IsometricStageProps) {
  const t = useTexts(TEXTS, labels);
  const plates = PLATES[variant];
  // Legend entries map to plates from the bottom up; the workflow floor carries no entry
  const offset = variant === 'workflow' ? 1 : 0;
  const count = Math.min(items.length, plates.length - offset);
  const [apart, setApart] = useState(true);
  const [focus, setFocus] = useState(-1);
  const [watch, active] = useActive<HTMLDivElement>();
  const stage = useRef<HTMLDivElement>(null);

  // Starts together when below the window, comes apart once it scrolls in
  useEffect(() => {
    const el = stage.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.75) return;
    setApart(false);
    const io = new IntersectionObserver(([e]) => {
      if (!e.isIntersecting) return;
      window.setTimeout(() => setApart(true), 250);
      io.disconnect();
    }, { rootMargin: '0px 0px -25% 0px' });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const gap = variant === 'stack' ? 30 : variant === 'workflow' ? 46 : 34;

  const legend = (
    <div className={cn('max-lg:contents', reverse && 'lg:order-2')}>
      {title && (
        <Reveal className="max-lg:order-1">
          {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
          <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>
          {description && <p className="mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
        </Reveal>
      )}
      <ol aria-label={t.layers} className={cn('grid gap-1.5 max-lg:order-3', title && 'lg:mt-8')} onPointerLeave={() => setFocus(-1)}>
        {items.slice(0, count).map((it, i) => {
          const k = variant === 'stack' || variant === 'page' ? count - 1 - i : i;
          const on = focus === k;
          return (
            <li key={i}>
              <button
                type="button"
                aria-pressed={on}
                onPointerEnter={() => setFocus(k)}
                onFocus={() => setFocus(k)}
                onBlur={() => setFocus(-1)}
                onClick={() => setFocus(on ? -1 : k)}
                className={cn(
                  'flex min-h-11 w-full cursor-pointer items-start gap-3.5 rounded-xl border p-3.5 text-left transition-colors duration-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary',
                  on ? 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]' : 'border-transparent hover:bg-accent',
                )}
              >
                <span className={cn('flex size-8 shrink-0 items-center justify-center rounded-lg text-[12.5px] font-semibold tabular-nums transition-colors', on ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] text-muted-foreground')}>
                  {it.icon ? <Icon name={it.icon} size={15} /> : i + 1}
                </span>
                <span className="min-w-0">
                  <span className="block text-[15px] font-semibold text-foreground">{it.title}</span>
                  {it.text && <span className="mt-0.5 block text-[14px] leading-relaxed text-muted-foreground">{it.text}</span>}
                </span>
              </button>
            </li>
          );
        })}
      </ol>
      <button
        type="button"
        onClick={() => setApart((v) => !v)}
        aria-pressed={!apart}
        className="w-fit inline-flex min-h-11 cursor-pointer max-lg:order-4 max-lg:-mt-6 lg:mt-5 items-center gap-2 rounded-lg px-3 text-[14px] font-medium text-primary transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary"
      >
        <Icon name={apart ? 'minimize' : 'layers'} size={16} />
        {apart ? t.assemble : t.explode}
      </button>
    </div>
  );

  const picture = (
    <div ref={watch} aria-hidden className="relative mx-auto w-full max-w-[36rem] [container-type:inline-size]">
      <div ref={stage} className="relative h-[86cqw] [perspective:none]">
        <div
          className="absolute left-1/2 top-[54%] [transform-style:preserve-3d]"
          style={{ width: '58cqw', height: '58cqw', transform: 'translate(-50%, -50%) rotateX(56deg) rotateZ(-42deg)' }}
        >
          {/* soft shadow on the ground */}
          <span className="absolute inset-[-6%] rounded-[12%] bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] blur-xl dark:bg-[color-mix(in_oklab,black_55%,transparent)]" />
          {plates.map((pl, i) => {
            const k = i - offset;
            const has = k >= 0 && k < count;
            const on = has && focus === k;
            const dim = focus >= 0 && has && !on;
            const z = round((apart ? pl.z * gap : pl.z * (variant === 'workflow' ? 0 : 7)) + (on ? 26 : 0), 1);
            const face = pl.tone === 'accent' ? tint(26) : pl.tone === 'surface' ? 'var(--surface)' : 'var(--card)';
            return (
              <div
                key={i}
                className="absolute [transform-style:preserve-3d] transition-[transform,opacity] duration-[900ms] motion-reduce:transition-none"
                style={{ left: `${pl.x}%`, top: `${pl.y}%`, width: `${pl.w}%`, height: `${pl.h}%`, transform: `translateZ(${z}px)`, opacity: dim ? 0.42 : 1, transitionTimingFunction: ease3d, transitionDelay: apart ? `${i * 60}ms` : '0ms' }}
              >
                <Slab depth={`${pl.depth ?? 7}px`} layers={pl.depth && pl.depth > 12 ? 8 : 4} radius="8px" edge={pl.tone === 'accent' ? 'color-mix(in oklab, var(--primary) 55%, var(--card))' : 'color-mix(in oklab, var(--foreground) 16%, var(--card))'} className="size-full" faceClassName="size-full">
                  <div className={cn('relative size-full overflow-hidden rounded-[8px] border', on ? 'border-primary' : 'border-border dark:border-border-strong')} style={{ background: pl.tone === 'accent' && variant === 'workflow' ? 'var(--primary)' : face }}>
                    {pl.draw(on)}
                    {variant === 'workflow' && i === 0 && (
                      <svg viewBox="0 0 100 100" className="absolute inset-0 size-full" fill="none">
                        <path d={FLOW} stroke={ink(22)} strokeWidth="1.4" strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
                        <path d={FLOW} stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" pathLength={100} strokeDasharray="10 90" vectorEffect="non-scaling-stroke" className={cn('[stroke-dashoffset:0]', active && 'animate-[kit-flow_3.2s_linear_infinite] [--kit-dash:50]')} />
                      </svg>
                    )}
                  </div>
                </Slab>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );

  return (
    <section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
      <div className={cn('kit-container grid grid-cols-1 items-center gap-10 lg:gap-16', reverse ? 'lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]' : 'lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]')}>
        {legend}
        <div className={cn('max-lg:order-2 lg:row-span-1', reverse && 'lg:order-1')}>{picture}</div>
      </div>
    </section>
  );
}

Frequent questions

How do I install Isometric stage?

With aiw add @ai-website/isometric-stage. The command puts components/ui/isometric-stage.tsx into your project, the code is yours from then on. It brings along section, icon, reveal and depth. It needs no further npm packages.

Which variants does Isometric stage have?

4 variants, Dashboard taken apart, System stack, Page in sections and Workflow on a floor. You pick one with the variant prop, for example variant="stack".

Can I translate the texts of Isometric stage?

Yes. Isometric stage carries 3 texts of its own in English and German, such as “Take apart”, “Put together” and “Layers”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Isometric stage?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Isometric stage with aiw_components and install it with aiw_add @ai-website/isometric-stage. Name the variant in your prompt, for example variant="dashboard", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard