Skip to content
UI Kit
Accent color

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.

4 variantsOne of 6 components in 3D scenes

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

Chapters in depthNew in 1.2

Text chapters stand at depth, alternating left and right, over a drawn floor that runs to the horizon. A thin line at the bottom shows how far the flight has come.

Years into the distanceNew in 1.2

Large years with their story, one after another along a line that runs into the distance. Good for a company history or the road of a product.

Manifesto in flightNew in 1.2

One large statement after the other comes toward you out of the dark and passes by. The calmest of the four, for values and a manifesto.

Details

What Camera flight brings along, read from the registry and the source.

Category
3D scenes
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/camera-flight.tsx
Brings along
utilskitsectionbuttoniconrevealscroll-scene
npm packages
None
Languages
Texts come through props
Studio
Adjust in code
In collections
3D and motion

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'>.

  • variantoptional
    CameraFlightVariant

    chaptersgallerytimelinewords

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode
  • descriptionoptional
    string
  • stopsrequired
    FlightStop[]
  • mediaoptional
    React.ReactNode[]

    gallery: one picture per stop

  • actionoptional
    { label: string; href: string }

    at the end of the flight

  • paceoptional
    number

    Screens 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
components/ui/camera-flight.tsx
'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.

Often searched

Jump to

Copied to the clipboard