Kameraflug beim Scrollen
Eine Kamera, die beim Scrollen durch Ebenen voller Inhalt nach vorn fliegt. Jede Ebene kommt aus dem Dunst, wird vor dir scharf und zieht vorbei. Ohne JavaScript stehen die Ebenen als ruhige Liste da.
Flieg durch deine Inhalte
Ein Kameraflug bewegt Besucher beim Scrollen durch Ebenen voller Inhalt nach vorn. Jede Ebene kommt aus dem Dunst, wird vor dir scharf und zieht vorbei. Ohne JavaScript stehen die Ebenen als ruhige Liste da, Suchmaschinen und alle Besucher bekommen also den ganzen Text.
„Kapitel in der Tiefe“ setzt Textkapitel links und rechts über einen gezeichneten Boden, eine feine Linie unten zeigt den Fortschritt. „Galerie im Raum“ verteilt Bilder mit Bildunterschrift im Raum, am Handy rücken sie näher zur Mitte. „Jahre in die Tiefe“ führt große Jahreszahlen mit ihrer Geschichte an einer Linie entlang, für eine Firmengeschichte oder den Weg eines Produkts. „Manifest im Flug“ holt eine Aussage nach der anderen aus dem Dunkel, die ruhigste der vier.
Fünf bis acht Ebenen ergeben einen guten Flug. Halte jede Ebene bei einer Überschrift und einem Satz. Setz den Flug hinter den Einstieg, wenn Besucher sich fürs Bleiben entschieden haben.
Variants
Details
Was Kameraflug mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Szenen
- Bereich
- Visuals
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/camera-flight.tsx
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- In Sammlungen
- 3D und Bewegung
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CameraFlightProps aus components/ui/camera-flight.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalCameraFlightVariantchaptersgallerytimelinewords
eyebrowoptionalstringtitlePflichtReact.ReactNodedescriptionoptionalstringstopsPflichtFlightStop[]mediaoptionalReact.ReactNode[]gallery: one picture per stop
actionoptional{ label: string; href: string }at the end of the flight
paceoptionalnumberScreens of scrolling per stop. Default 0.9.
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- 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).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 268 Zeilen
'use client';
import { useCallback, useEffect, useId, 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 { scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A camera that flies through space while the page scrolls. The content stands on planes one behind
* the other; the section stays in place for a few screens and the camera travels forward, each
* plane comes out of the haze, sharpens in front of the viewer and passes by. Only transform and
* opacity are written, straight onto each plane, nothing repaints while scrolling.
*
* Without JavaScript and with reduced motion there is no flight: the planes stand as a calm list
* in the page. Links inside a plane stay in the tab order, tabbing to one flies the camera there.
*
* chapters text chapters at depth, alternating left and right, over a drawn floor
* gallery pictures scattered in space with captions, the camera weaves between them
* timeline years with their story along a line that runs into the distance
* words a manifesto, one large statement after the other comes toward the viewer
*/
export type CameraFlightVariant = 'chapters' | 'gallery' | 'timeline' | 'words';
export interface FlightStop {
title: string;
text?: string;
/** chapters: a short line above; timeline: the year; gallery: the place */
meta?: string;
icon?: IconName;
href?: string;
}
export interface CameraFlightProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: CameraFlightVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
stops: FlightStop[];
/** gallery: one picture per stop */
media?: React.ReactNode[];
/** at the end of the flight */
action?: { label: string; href: string };
/** Screens of scrolling per stop. Default 0.9. */
pace?: number;
}
/** Distance between two planes in px */
const GAP = 900;
const PERSPECTIVE = 1000;
/** Where a plane stands on the screen, in share of the stage (x of half width, y of half height) */
const PLACE: Record<CameraFlightVariant, (i: number) => { x: number; y: number }> = {
chapters: (i) => ({ x: i % 2 ? 0.34 : -0.34, y: 0.02 }),
gallery: (i) => ({ x: [-0.5, 0.48, -0.22, 0.56, -0.54, 0.3][i % 6], y: [-0.18, 0.12, 0.24, -0.22, 0.16, -0.06][i % 6] }),
timeline: (i) => ({ x: i % 2 ? 0.3 : -0.3, y: -0.06 }),
words: () => ({ x: 0, y: 0 }),
};
function Head({ eyebrow, title, description, center = true }: { eyebrow?: string; title: React.ReactNode; description?: string; center?: boolean }) {
return (
<div className={cn('max-w-[44rem]', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-balance text-h1 font-semibold text-foreground">{title}</h2>
{description && <p className={cn('mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</div>
);
}
/** One stop as it reads on its plane */
function Stop({ variant, stop, index, media }: { variant: CameraFlightVariant; stop: FlightStop; index: number; media?: React.ReactNode }) {
if (variant === 'words')
return (
<p className="max-w-[16ch] text-balance text-center text-[clamp(2.4rem,1.2rem+5vw,5.6rem)] font-semibold leading-[1] tracking-[-0.045em] text-foreground">
{stop.title}
{stop.text && <span className="mt-5 block text-pretty text-lead font-normal tracking-normal text-muted-foreground">{stop.text}</span>}
</p>
);
if (variant === 'gallery')
return (
<figure className="w-[min(72vw,22rem)]">
<div className="aspect-[4/5] overflow-hidden rounded-2xl bg-surface shadow-[var(--shadow-lg)] [&>*]:size-full">{media}</div>
<figcaption className="mt-3 flex items-baseline justify-between gap-3">
<span className="text-[15px] font-semibold text-foreground">{stop.title}</span>
{stop.meta && <span className="text-[13px] text-muted-foreground">{stop.meta}</span>}
</figcaption>
</figure>
);
if (variant === 'timeline')
return (
<div className="w-[min(80vw,24rem)]">
<p className="text-[clamp(3.4rem,2rem+5vw,6.5rem)] font-semibold leading-none tracking-[-0.05em] text-primary tabular-nums">{stop.meta}</p>
<div className="mt-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
<h3 className="text-[18px] font-semibold text-foreground">{stop.title}</h3>
{stop.text && <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{stop.text}</p>}
</div>
</div>
);
return (
<div className="w-[min(84vw,26rem)] rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name={stop.icon ?? 'sparkle'} size={19} />
</span>
<span className="text-[13px] font-medium tabular-nums text-muted-foreground">{stop.meta ?? String(index + 1).padStart(2, '0')}</span>
</div>
<h3 className="mt-6 text-h3 font-semibold text-foreground">{stop.title}</h3>
{stop.text && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{stop.text}</p>}
{stop.href && (
<a href={stop.href} className="mt-5 inline-flex min-h-11 cursor-pointer items-center gap-1.5 text-[14px] font-medium text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary">
{stop.title}
<Icon name="arrow-right" size={15} />
</a>
)}
</div>
);
}
/** Lines converging on the horizon and rows that close up toward it, drawn once */
function FloorLines() {
const id = useId().replace(/:/g, '');
const rows = Array.from({ length: 9 }, (_, r) => round(45 + 55 / (1 + r * 0.8), 2));
const cols = Array.from({ length: 17 }, (_, c) => (c - 8) * 26);
return (
<svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 size-full">
<defs>
<linearGradient id={`${id}f`} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="100">
<stop offset="0.46" stopColor="var(--foreground)" stopOpacity="0" />
<stop offset="1" stopColor="var(--foreground)" stopOpacity="0.16" />
</linearGradient>
</defs>
<g stroke={`url(#${id}f)`} strokeWidth="1" vectorEffect="non-scaling-stroke" fill="none">
{cols.map((x, i) => (
<line key={i} x1="50" y1="45" x2={round(50 + x, 2)} y2="100" vectorEffect="non-scaling-stroke" />
))}
{rows.map((y, i) => (
<line key={`r${i}`} x1="0" y1={y} x2="100" y2={y} vectorEffect="non-scaling-stroke" />
))}
</g>
</svg>
);
}
/* ------------------------------------------------------------------ */
/** A camera flight through planes of content while the page scrolls. */
export function CameraFlight({ variant = 'chapters', eyebrow, title, description, stops, media = [], action, pace = 0.9, className, onFocus, ...rest }: CameraFlightProps) {
const live = useLive();
const planes = useRef<(HTMLElement | null)[]>([]);
const extra = useRef<Record<string, HTMLElement | null>>({});
const size = useRef({ w: 1280, h: 800 });
const last = useRef(0);
const n = stops.length;
// The head is the first plane, the action the last; the camera ends on the action
const total = n + 1;
const apply = useCallback(
(p: number) => {
last.current = p;
const { w, h } = size.current;
const narrow = w < 640;
const cam = smooth(span(p, 0.02, 0.98)) * total * GAP;
for (let i = 0; i <= total; i++) {
const el = planes.current[i];
if (!el) continue;
// z: where the plane stands in front of the camera (0 is the screen, negative is far)
const z = -i * GAP + cam;
const at = i === 0 || i === total ? { x: 0, y: 0 } : PLACE[variant](i - 1);
const spread = narrow ? 0.18 : 1;
const x = at.x * spread * w * 0.5;
const y = at.y * h * 0.5;
// Out of the haze (far), sharp near the screen, gone once it passes the camera
const fadeIn = span(z, -GAP * 0.95, -GAP * 0.3);
const fadeOut = 1 - span(z, PERSPECTIVE * 0.04, PERSPECTIVE * 0.32);
const o = Math.min(fadeIn, fadeOut);
el.style.transform = `translate(-50%, -50%) translate3d(${round(x, 1)}px, ${round(y, 1)}px, ${round(Math.min(z, PERSPECTIVE * 0.7), 1)}px)`;
el.style.opacity = String(round(o, 3));
el.style.visibility = o <= 0.001 ? 'hidden' : '';
}
const bar = extra.current.bar;
if (bar) bar.style.transform = `scaleX(${round(p, 4)})`;
},
[variant, total],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
useEffect(() => {
if (!live) return;
const stage = extra.current.stage;
if (!stage) return;
const measure = () => {
size.current = { w: stage.clientWidth, h: stage.clientHeight };
apply(last.current);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(stage);
return () => ro.disconnect();
}, [live, apply]);
const actionEl = action && (
<div className="max-sm:w-full max-sm:[&>span]:w-full">
<Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
{action.label}
</Button>
</div>
);
/* ---------- still layout ---------- */
if (!live) {
return (
<section {...rest} onFocus={onFocus} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
<div className="kit-container">
<Reveal>
<Head eyebrow={eyebrow} title={title} description={description} />
</Reveal>
<ol className={cn('mt-14 grid grid-cols-1 gap-6 sm:mt-16', variant === 'words' ? 'justify-items-center gap-16' : variant === 'gallery' ? 'justify-items-center sm:grid-cols-2 lg:grid-cols-3' : 'justify-items-center md:grid-cols-2')}>
{stops.map((s, i) => (
<li key={i} className={cn(variant === 'timeline' || variant === 'chapters' ? (i % 2 ? 'md:translate-y-16' : '') : '')}>
<Stop variant={variant} stop={s} index={i} media={media[i % (media.length || 1)]} />
</li>
))}
</ol>
{actionEl && <div className="mt-16 flex justify-center">{actionEl}</div>}
</div>
</section>
);
}
/* ---------- the flight ---------- */
const onSceneFocus = (e: React.FocusEvent<HTMLElement>) => {
onFocus?.(e);
const target = e.target as Node;
const i = planes.current.findIndex((el) => el?.contains(target));
if (i >= 0) scrollToProgress(track.current, Math.min(1, (i / total) * 0.96 + 0.02));
};
return (
<section {...rest} onFocus={onSceneFocus} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${round((total * pace + 1) * 100, 1)}svh` }}>
<div ref={(el) => void (extra.current.stage = el)} data-clip-ok className="sticky top-0 h-svh overflow-clip [perspective:1000px] [perspective-origin:50%_45%]">
{/* a flat drawing of a floor running to the horizon: no 3D layer, so the flight stays at full frame rate */}
{variant !== 'words' && <FloorLines />}
{variant === 'timeline' && <span aria-hidden className="absolute left-1/2 top-[45%] h-[55%] w-px -translate-x-1/2 bg-gradient-to-b from-transparent to-[color-mix(in_oklab,var(--primary)_60%,transparent)]" />}
<div className="absolute inset-0 [transform-style:preserve-3d]">
<div ref={(el) => void (planes.current[0] = el)} className="absolute left-1/2 top-1/2 w-[min(92vw,46rem)] will-change-transform">
<Head eyebrow={eyebrow} title={title} description={description} />
</div>
{stops.map((s, i) => (
<div key={i} ref={(el) => void (planes.current[i + 1] = el)} className="absolute left-1/2 top-1/2 will-change-transform" style={{ opacity: 0 }}>
<Stop variant={variant} stop={s} index={i} media={media[i % (media.length || 1)]} />
</div>
))}
<div ref={(el) => void (planes.current[total] = el)} className="absolute left-1/2 top-1/2 flex w-[min(92vw,40rem)] flex-col items-center gap-6 text-center will-change-transform" style={{ opacity: 0 }}>
{actionEl}
</div>
</div>
<span aria-hidden className="absolute inset-x-0 bottom-0 h-0.5 bg-track">
<span ref={(el) => void (extra.current.bar = el)} className="absolute inset-0 origin-left bg-primary" style={{ transform: 'scaleX(0)' }} />
</span>
</div>
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Kameraflug?
Mit aiw add @ai-website/camera-flight. Der Befehl legt die Datei components/ui/camera-flight.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, icon, reveal und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kameraflug?
4 Varianten, Kapitel in der Tiefe, Galerie im Raum, Jahre in die Tiefe und Manifest im Flug. Du wählst sie mit der Prop variant, zum Beispiel variant="gallery".
Kann ein KI-Assistent Kameraflug einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kameraflug mit aiw_components und installieren sie mit aiw_add @ai-website/camera-flight. Nenn im Prompt die Variante, etwa variant="chapters", dann schreibt der Assistent deine Inhalte direkt in die Props.