Perspektivboden
Ein Boden aus Linien oder Punkten bis zu einem weichen Horizont, als Bühne für eine Überschrift, Karten, Zahlen oder ein Angebot. Er strömt beim Scrollen auf dich zu und treibt langsam, solange er zu sehen ist, Text steht immer auf ruhigem Grund.
Eine Bühne bis zum Horizont
Ein Perspektivboden gibt einer Section Tiefe, mit einem Boden aus Linien oder Punkten bis zu einem weichen Horizont. Er strömt beim Scrollen auf dich zu und treibt langsam, solange er zu sehen ist, und Text steht immer auf ruhigem Grund, Überschriften behalten also ihren Kontrast.
„Auftakt über einem Raster“ setzt eine große Überschrift über einen Boden aus Akzentlinien, der am Horizont leuchtet, ein starker Einstieg für ein Produkt oder ein Event. „Karten auf einer Bühne“ stellt drei Karten mit weicher Spiegelung auf einen Punkteboden, die mittlere einen Schritt vorn. „Zahlen als Säulen“ lässt Zahlen als Säulen aus dem Boden wachsen, jeder Wert steht als Text in der Legende. „Angebot über dem Horizont“ lässt eine Karte mit Angebot und Knopf über einem Lichthorizont schweben.
Nimm den Boden einmal pro Seite, für den Auftakt oder das Schlussangebot. Spar den Akzent für Horizont und einen Knopf. Die Säulen passen zu drei bis fünf Zahlen.
Variants
Details
Was Perspektivboden mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Szenen
- Bereich
- Visuals
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/perspective-floor.tsx
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PerspectiveFloorProps aus components/ui/perspective-floor.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalPerspectiveFloorVariantgridstagebarshorizon
eyebrowoptionalstringtitlePflichtReact.ReactNodedescriptionoptionalstringitemsoptionalFloorItem[]patternoptional'lines' | 'dots'The floor pattern. grid and bars default to lines, stage and horizon to dots.
actionoptional{ label: string; href: string }secondaryoptional{ label: string; href: string }driftoptionalbooleanDrift on its own while on screen. Default true.
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- 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.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 295 Zeilen
'use client';
import { useCallback, useEffect, useRef } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { seg, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
import { useActive } from '@/components/ui/depth';
/*
* A floor with depth as a stage. A plane of lines or dots runs from the viewer to a soft horizon,
* the content stands above it or on it. The floor streams toward the viewer while the page scrolls
* and drifts slowly on its own while it is on screen; the drift pauses outside the window and in a
* hidden tab. Only one transform per frame is written.
*
* The heading never stands on the floor itself: the horizon sits below it and fades into the
* background, so text keeps the calm page colour behind it.
*
* grid an opening section, heading above a floor of lines that glows at the horizon
* stage cards standing on the floor with a soft reflection, the camera swings with the scroll
* bars figures as solid bars rising out of the floor, with a legend in plain text
* horizon a floor of dots toward a horizon of light, a card floats above it
*
* Without JavaScript and with reduced motion the floor stands still and the bars stand at full height.
*/
export type PerspectiveFloorVariant = 'grid' | 'stage' | 'bars' | 'horizon';
export interface FloorItem {
title: string;
text?: string;
/** bars: the value shown, e.g. "86 %" */
meta?: string;
/** bars: the height, 0 to 100 */
value?: number;
icon?: IconName;
}
export interface PerspectiveFloorProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: PerspectiveFloorVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
items?: FloorItem[];
/** The floor pattern. grid and bars default to lines, stage and horizon to dots. */
pattern?: 'lines' | 'dots';
action?: { label: string; href: string };
secondary?: { label: string; href: string };
/** Drift on its own while on screen. Default true. */
drift?: boolean;
}
const CELL = 64;
/** The floor plane: a wide sheet laid back into the room, its far edge fading out */
function Floor({ pattern, setRef, tilt = 74, top = '58%', glow = true }: { pattern: 'lines' | 'dots'; setRef: (el: HTMLDivElement | null) => void; tilt?: number; top?: string; glow?: boolean }) {
const lines = `linear-gradient(to right, color-mix(in oklab, var(--primary) 34%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--primary) 34%, transparent) 1px, transparent 1px)`;
const dots = 'radial-gradient(color-mix(in oklab, var(--foreground) 30%, transparent) 1.4px, transparent 1.8px)';
return (
<div aria-hidden data-clip-ok className="pointer-events-none absolute inset-x-0 bottom-0 [perspective:520px] [perspective-origin:50%_0%]" style={{ top }}>
{glow && <span className="absolute inset-x-[10%] -top-16 h-32 rounded-[50%] bg-[color-mix(in_oklab,var(--primary)_32%,transparent)] blur-3xl" />}
<div className="absolute inset-x-[-120%] top-0 h-[260%] origin-top [transform-style:preserve-3d]" style={{ transform: `rotateX(${tilt}deg)` }}>
<div
ref={setRef}
data-clip-ok
className="absolute inset-x-0 top-[-128px] bottom-0 will-change-transform"
style={{
backgroundImage: pattern === 'lines' ? lines : dots,
backgroundSize: pattern === 'lines' ? `${CELL}px ${CELL}px` : `${CELL / 2}px ${CELL / 2}px`,
backgroundPosition: 'center top',
maskImage: 'linear-gradient(to bottom, transparent 0%, black 18%, black 55%, transparent 100%)',
}}
/>
</div>
{/* the horizon melts into the page */}
<span className="absolute inset-x-0 top-0 h-[38%] bg-gradient-to-b from-background to-transparent" />
</div>
);
}
function Head({ eyebrow, title, description, action, secondary, big = false }: Pick<PerspectiveFloorProps, 'eyebrow' | 'title' | 'description' | 'action' | 'secondary'> & { big?: boolean }) {
return (
<Reveal className="relative z-10 mx-auto max-w-[46rem] text-center">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className={cn('text-balance font-semibold text-foreground', big ? 'text-display leading-[1.02] tracking-[-0.035em]' : 'text-h1')}>{title}</h2>
{description && <p className="mx-auto mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{(action || secondary) && (
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
{action && (
<span className="max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</span>
)}
{secondary && (
<Button href={secondary.href} variant="secondary" className="max-sm:w-full">
{secondary.label}
</Button>
)}
</div>
)}
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/** A floor with depth as a stage for a heading, cards, figures or a single offer. */
export function PerspectiveFloor({ variant = 'grid', eyebrow, title, description, items = [], pattern, action, secondary, drift = true, className, ...rest }: PerspectiveFloorProps) {
const live = useLive();
const floor = useRef<HTMLDivElement | null>(null);
const cam = useRef<HTMLUListElement | null>(null);
const prog = useRef(0);
const [watch, active] = useActive<HTMLDivElement>();
const pat = pattern ?? (variant === 'grid' || variant === 'bars' ? 'lines' : 'dots');
const unit = pat === 'lines' ? CELL : CELL / 2;
const write = useCallback(
(t: number) => {
const el = floor.current;
if (el) el.style.transform = `translate3d(0, ${round((prog.current * unit * 8 + t * 0.012) % (unit * 2), 2)}px, 0)`;
},
[unit],
);
const onProgress = useCallback(
(p: number) => {
prog.current = p;
write(0);
if (variant === 'stage' && cam.current) cam.current.style.transform = `rotateX(8deg) rotateY(${round((p - 0.5) * 22, 2)}deg)`;
},
[variant, write],
);
const scene = useSceneProgress<HTMLElement>(live, { mode: variant === 'bars' ? 'enter' : 'pass', self: variant === 'bars', onProgress });
// A slow drift while on screen, paused outside the window and in hidden tabs
useEffect(() => {
if (!live || !active || !drift || variant === 'bars') return;
let frame = 0;
let t0 = 0;
const loop = (now: number) => {
if (!t0) t0 = now;
write(now - t0);
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, [live, active, drift, variant, write]);
const setFloor = (el: HTMLDivElement | null) => {
floor.current = el;
};
/* ---------- grid ---------- */
if (variant === 'grid') {
return (
<section {...rest} ref={scene} className={cn('relative overflow-hidden', className)}>
<div ref={watch} className="relative flex min-h-[min(92svh,52rem)] flex-col items-center justify-start px-6 pb-[32svh] pt-[clamp(4rem,14svh,9rem)] sm:pb-[30svh]">
<Floor pattern={pat} setRef={setFloor} top="62%" />
<Head eyebrow={eyebrow} title={title} description={description} action={action} secondary={secondary} big />
</div>
</section>
);
}
/* ---------- horizon ---------- */
if (variant === 'horizon') {
const it = items[0];
return (
<section {...rest} ref={scene} className={cn('relative overflow-hidden', className)}>
<div ref={watch} className="relative px-6 pb-16 pt-20 sm:pb-24 sm:pt-28">
<span aria-hidden className="pointer-events-none absolute left-1/2 top-[56%] h-48 w-[min(96vw,64rem)] -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_45%,transparent),transparent)] blur-2xl" />
<Floor pattern={pat} setRef={setFloor} top="56%" tilt={78} glow={false} />
<Head eyebrow={eyebrow} title={title} description={description} />
{it && (
<div className="relative z-10 mx-auto mt-12 max-w-[30rem] sm:mt-16">
<div className={cn('rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] motion-safe:animate-kit-float sm:p-7', !active && '[animation-play-state:paused]')}>
<div className="flex items-start gap-4">
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name={it.icon ?? 'sparkle'} size={20} />
</span>
<div className="min-w-0">
<p className="text-[17px] font-semibold text-foreground">{it.title}</p>
{it.text && <p className="mt-1 text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
</div>
{it.meta && <span className="ml-auto shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{it.meta}</span>}
</div>
{action && (
<div className="mt-5">
<Button href={action.href} icon="arrow-right" className="w-full">
{action.label}
</Button>
</div>
)}
</div>
</div>
)}
<div className="h-[18svh]" />
</div>
</section>
);
}
/* ---------- stage ---------- */
if (variant === 'stage') {
return (
<section {...rest} ref={scene} className={cn('relative overflow-hidden py-20 sm:py-28', className)}>
<div ref={watch} className="relative">
<div className="px-6"><Head eyebrow={eyebrow} title={title} description={description} action={action} /></div>
<div className="relative mt-12 sm:mt-16">
<Floor pattern={pat} setRef={setFloor} top="46%" tilt={70} />
<div className="kit-container relative [perspective:1400px]">
<ul ref={cam} className="grid grid-cols-1 gap-6 [transform-style:preserve-3d] sm:grid-cols-3" style={{ transform: 'rotateX(8deg) rotateY(0deg)' }}>
{items.slice(0, 3).map((it, i) => (
<li key={i} className="[transform-style:preserve-3d]" style={{ transform: `translateZ(${i === 1 ? 40 : 0}px)` }}>
<div className="relative rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)]">
<span className="flex size-11 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name={it.icon ?? 'sparkle'} size={20} />
</span>
<p className="mt-5 text-[18px] font-semibold text-foreground">{it.title}</p>
{it.text && <p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
{it.meta && <p className="mt-5 text-[13px] font-medium text-primary">{it.meta}</p>}
</div>
{/* a soft reflection on the floor */}
<div aria-hidden className="pointer-events-none mt-2 h-16 rounded-2xl bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--primary)_14%,transparent),transparent)] opacity-70 blur-[2px] max-sm:hidden" />
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
/* ---------- bars ---------- */
const max = Math.max(1, ...items.map((it) => it.value ?? 0));
const bars = items.slice(0, 6);
return (
<section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div ref={watch} className="kit-container">
<Head eyebrow={eyebrow} title={title} description={description} />
<div className="mt-10 grid grid-cols-1 items-center gap-10 sm:mt-14 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)]">
<div aria-hidden className="relative mx-auto aspect-[1.35] w-full max-w-[40rem] [container-type:inline-size]">
<div className="absolute left-1/2 top-[64%] [transform-style:preserve-3d]" style={{ width: '62cqw', height: '40cqw', transform: 'translate(-50%, -50%) rotateX(58deg) rotateZ(-34deg)' }}>
<span
className="absolute inset-[-8%] rounded-xl"
style={{
backgroundImage: `linear-gradient(to right, color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 12%, transparent) 1px, transparent 1px)`,
backgroundSize: '8% 12.5%',
maskImage: 'radial-gradient(closest-side, black 60%, transparent)',
}}
/>
{bars.map((it, i) => {
const h = round(((it.value ?? 0) / max) * 46, 2);
const s = seg(0.1 + i * 0.08, 0.7 + i * 0.05);
const w = 100 / bars.length;
const box = { left: `${round(i * w + w * 0.2, 2)}%`, width: `${round(w * 0.6, 2)}%`, top: '30%', height: '40%' } as const;
const top = i === bars.length - 1 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 55%, var(--card))';
return (
<div key={i} className="absolute [transform-style:preserve-3d]" style={box}>
{/* front, side and top of a solid bar */}
<span className="absolute inset-x-0 top-full origin-top" style={{ height: `${h}cqw`, background: `color-mix(in oklab, ${top} 78%, black)`, transform: `rotateX(90deg) scaleY(${s})` }} />
<span className="absolute inset-y-0 left-full origin-left" style={{ width: `${h}cqw`, background: `color-mix(in oklab, ${top} 62%, black)`, transform: `rotateY(-90deg) scaleX(${s})` }} />
<span className="absolute inset-0 rounded-[2px]" style={{ background: top, transform: `translateZ(calc(${h}cqw * ${s}))` }} />
</div>
);
})}
</div>
</div>
<ol className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-1">
{bars.map((it, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 rounded-xl border border-border bg-card px-4 py-3 shadow-[var(--highlight)]">
<span className="flex min-w-0 items-center gap-2 text-[14px] text-muted-foreground">
<span className={cn('size-2.5 shrink-0 rounded-[3px]', i === bars.length - 1 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))]')} />
<span className="truncate">{it.title}</span>
</span>
<span className="shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{it.meta}</span>
</li>
))}
</ol>
</div>
{action && (
<div className="mt-10 flex justify-center">
<Button href={action.href} variant="secondary" icon="arrow-right">
{action.label}
</Button>
</div>
)}
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Perspektivboden?
Mit aiw add @ai-website/perspective-floor. Der Befehl legt die Datei components/ui/perspective-floor.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, icon, reveal, scroll-scene und depth. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Perspektivboden?
4 Varianten, Auftakt über einem Raster, Karten auf einer Bühne, Zahlen als Säulen und Angebot über dem Horizont. Du wählst sie mit der Prop variant, zum Beispiel variant="stage".
Kann ein KI-Assistent Perspektivboden einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Perspektivboden mit aiw_components und installieren sie mit aiw_add @ai-website/perspective-floor. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.