Camera flight on scroll
A camera that flies forward through planes of content while the page scrolls. Each plane comes out of the haze, sharpens in front of you and passes by. Without JavaScript the planes stand as a calm list.
Fly through your content
A camera flight moves the visitor forward through planes of content while the page scrolls. Each plane comes out of the haze, sharpens in front of you and passes by. Without JavaScript the planes stand as a calm list, so search engines and every visitor get the whole text.
Chapters in depth set text chapters left and right over a drawn floor, with a thin line at the bottom for the progress. Gallery in space scatters pictures with their captions through the room, on phones they line up closer to the middle. Years into the distance runs large years with their story along a line, for a company history or the road of a product. Manifesto in flight brings one statement after another out of the dark, the calmest of the four.
Five to eight planes make a good flight. Keep each plane to a heading and a sentence. Place the flight after the opening, once visitors have decided to stay.
Variants
Details
What Camera flight brings along, read from the registry and the source.
- Category
- 3D scenes
- Area
- Visuals
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/camera-flight.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- In collections
- 3D and motion
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CameraFlightProps interface from components/ui/camera-flight.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalCameraFlightVariantchaptersgallerytimelinewords
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringstopsrequiredFlightStop[]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.
Accessibility
Read from the source of the component.
- 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).
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 268 lines
'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>
);
}Frequent questions
How do I install Camera flight?
With aiw add @ai-website/camera-flight. The command puts components/ui/camera-flight.tsx into your project, the code is yours from then on. It brings along section, button, icon, reveal and scroll-scene. It needs no further npm packages.
Which variants does Camera flight have?
4 variants, Chapters in depth, Gallery in space, Years into the distance and Manifesto in flight. You pick one with the variant prop, for example variant="gallery".
Can an AI assistant build with Camera flight?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Camera flight with aiw_components and install it with aiw_add @ai-website/camera-flight. Name the variant in your prompt, for example variant="chapters", and the assistant writes your content straight into the props.