Zum Inhalt springen
UI Kit
Accent

Isometrische Bühne

Ein Produkt oder System als Platten in einem isometrischen Raum, in seine Ebenen auseinandergehoben. Eine Legende daneben hebt bei Hover oder Fokus eine Platte an, ein Knopf setzt die Bühne zusammen und nimmt sie auseinander.

4 VariantsEine von 6 Components in 3D-Flächen

Ein Produkt, in Ebenen zerlegt

Eine isometrische Bühne erklärt, wie etwas aufgebaut ist, indem sie es auseinanderhebt. Die Platten stehen in einem isometrischen Raum, eine Legende daneben hebt bei Hover oder Fokus eine Platte an, und ein Knopf setzt die Bühne zusammen und nimmt sie wieder auseinander, die Erklärung funktioniert also mit Maus, Tastatur und Touch.

„Dashboard in Ebenen“ hebt ein Dashboard beim Hereinscrollen in Fenster, Navigation, Kennzahlen, Diagramm und Notiz auseinander. Der „Systemstapel“ schichtet vier gleiche Platten wie Etagen, von der Oberfläche bis zur Infrastruktur. „Seite in Abschnitten“ hebt die Abschnitte einer Website von der Navigation bis zum Footer auseinander, gut für einen Seitenbaukasten oder eine Agentur. „Ablauf auf einer Fläche“ stellt vier Schritte auf einen Boden, verbunden durch einen Weg, auf dem ein Signal läuft.

Benenne jede Ebene in der Legende mit dem, was sie für Kunden leistet. Vier oder fünf Platten lesen sich am besten. Setz die Bühne einmal pro Seite dort ein, wo der Aufbau selbst das Verkaufsargument ist.

Variants

Dashboard in EbenenNeu in 1.2

Ein Dashboard in fünf Ebenen, Fenster, Navigation, Kennzahlen, Diagramm und Hinweis, das beim Hereinscrollen auseinandergeht.

SystemstapelNeu in 1.2

Vier gleiche Platten, gestapelt wie Stockwerke, ein System von der Oberfläche bis zur Infrastruktur. Die Legende liest sich von oben nach unten.

Seite in AbschnittenNeu in 1.2

Die Abschnitte einer Website, von Navigation bis Fußzeile, auseinandergehoben. Zusammengesetzt ergeben sie eine Seite, gut für Baukästen und Agenturen.

Ablauf auf einer FlächeNeu in 1.2

Vier Schritte stehen auf einer Fläche, verbunden durch einen Weg, auf dem ein Signal läuft, solange die Bühne zu sehen ist. Zeigen hebt den Block an.

Details

Was Isometrische Bühne mitbringt, aus Registry und Quellcode gelesen.

Kategorie
3D-Flächen
Bereich
Visuals
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/isometric-stage.tsx
Bringt mit
utilskitsectioniconrevealdepth
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 3 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle IsometricStageProps aus components/ui/isometric-stage.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.

  • variantoptional
    IsometricStageVariant

    dashboardstackpageworkflow

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • itemsPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Isometrische Bühne selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 3 Texte anzeigen
explodeEnglisch Take apartDeutsch Auseinandernehmen
assembleEnglisch Put togetherDeutsch Zusammensetzen
layersEnglisch LayersDeutsch Ebenen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 268 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Isometrische Bühne?

Mit aiw add @ai-website/isometric-stage. Der Befehl legt die Datei components/ui/isometric-stage.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, icon, reveal und depth. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Isometrische Bühne?

4 Varianten, Dashboard in Ebenen, Systemstapel, Seite in Abschnitten und Ablauf auf einer Fläche. Du wählst sie mit der Prop variant, zum Beispiel variant="stack".

Kann ich die Texte von Isometrische Bühne übersetzen?

Ja. Isometrische Bühne bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Auseinandernehmen“, „Zusammensetzen“ und „Ebenen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Isometrische Bühne einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Isometrische Bühne mit aiw_components und installieren sie mit aiw_add @ai-website/isometric-stage. Nenn im Prompt die Variante, etwa variant="dashboard", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert