Tunnel zoom on scroll
A zoom through a tunnel while the page scrolls, lined with cards, framed pictures, rings with figures or a gate of light. The section stays, the camera travels, a closing text waits at the end.
The camera travels, the section stays
A tunnel zoom keeps the section in place while the camera travels through it. The walls are lined with your content, and at the end a closing text waits, so the effect leads somewhere.
Tunnel of cards lines walls, floor and ceiling with cards, on phones they stand on the two walls. Frame after frame sets framed pictures one behind the other, each fills the window as the camera arrives, its caption on a calm shade. Rings with figures runs rounded rings in the accent into the distance with a figure in each, like milestones on a road. Gate of light leads through rings of light to a round window whose picture opens to the full window.
Use the gate of light to reveal a product or a place, the rings for milestones and figures. Keep cards short, they pass by fast. Write the closing text as the next step, it is where the scroll ends.
Variants
Details
What Tunnel zoom 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/tunnel-zoom.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TunnelZoomProps interface from components/ui/tunnel-zoom.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalTunnelZoomVariantcardsframesringsportal
eyebrowoptionalstringtitlerequiredReact.ReactNodedescriptionoptionalstringitemsrequiredTunnelItem[]mediaoptionalReact.ReactNode[]cards, frames: pictures; portal: the picture behind the gate (first entry)
closingoptional{ title: string; text?: string }The text at the end of the tunnel
actionoptional{ label: string; href: string }
Accessibility
Read from the source of the component.
- 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, 314 lines
'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 { scrollToProgress, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A zoom through a tunnel while the page scrolls. The section stays in place, the camera travels
* into depth, and what lines the tunnel passes by: cards on the walls, framed pictures, rings with
* figures, a gate of light that opens onto a picture. Only transform and opacity are written onto
* each piece, nothing repaints while scrolling.
*
* Without JavaScript and with reduced motion there is no tunnel: heading, items and closing text
* stand in a calm layout. Links on the closing plane stay reachable, tabbing there scrolls to it.
*
* cards cards on the walls, floor and ceiling of a tunnel, the closing text waits at its end
* frames framed pictures one behind the other, each one fills the window and passes
* rings rounded rings into the distance, a figure stands in each one as you pass
* portal rings of light around a round window, the picture behind it opens to the full window
*/
export type TunnelZoomVariant = 'cards' | 'frames' | 'rings' | 'portal';
export interface TunnelItem {
title: string;
text?: string;
/** rings: the figure; frames: the place */
meta?: string;
icon?: IconName;
}
export interface TunnelZoomProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: TunnelZoomVariant;
eyebrow?: string;
title: React.ReactNode;
description?: string;
items: TunnelItem[];
/** cards, frames: pictures; portal: the picture behind the gate (first entry) */
media?: React.ReactNode[];
/** The text at the end of the tunnel */
closing?: { title: string; text?: string };
action?: { label: string; href: string };
}
type Piece = { x: number; y: number; z: number; rx: number; ry: number; kind: 'item' | 'head' | 'end' | 'ring' | 'gate' };
const D = 700;
const P = 900;
/** Where every piece of a tunnel stands before the camera moves (px, deg). w and h are the stage size. */
function layout(variant: TunnelZoomVariant, n: number, w: number, h: number): Piece[] {
const pieces: Piece[] = [{ x: 0, y: 0, z: 0, rx: 0, ry: 0, kind: 'head' }];
const narrow = w < 640;
if (variant === 'cards') {
// Cards stand on both sides and above and below, turned toward the middle like pages of a book
const hx = Math.min(w * (narrow ? 0.14 : 0.3), 420);
const hy = Math.min(h * 0.3, 240);
for (let i = 0; i < n; i++) {
const wall = narrow ? (i % 2 ? 1 : 3) : i % 4;
const z = -(i + 3) * D * 0.7;
if (wall === 0) pieces.push({ x: -hx * 0.4, y: -hy, z, rx: -48, ry: 0, kind: 'item' });
if (wall === 1) pieces.push({ x: hx, y: 0, z, rx: 0, ry: -52, kind: 'item' });
if (wall === 2) pieces.push({ x: hx * 0.4, y: hy, z, rx: 48, ry: 0, kind: 'item' });
if (wall === 3) pieces.push({ x: -hx, y: 0, z, rx: 0, ry: 52, kind: 'item' });
}
pieces.push({ x: 0, y: 0, z: -(n + 4) * D * 0.7, rx: 0, ry: 0, kind: 'end' });
} else if (variant === 'frames') {
for (let i = 0; i < n; i++) pieces.push({ x: 0, y: 0, z: -(i + 1.7) * D, rx: 0, ry: 0, kind: 'item' });
pieces.push({ x: 0, y: 0, z: -(n + 1.7) * D, rx: 0, ry: 0, kind: 'end' });
} else if (variant === 'rings') {
const rings = Math.ceil((n * 3 + 4) / 2);
for (let r = 0; r < rings; r++) pieces.push({ x: 0, y: 0, z: -(r + 1) * (D * 2) / 3, rx: 0, ry: 0, kind: 'ring' });
for (let i = 0; i < n; i++) pieces.push({ x: 0, y: 0, z: -(i * 3 + 4) * D / 3, rx: 0, ry: 0, kind: 'item' });
pieces.push({ x: 0, y: 0, z: -(n * 3 + 4) * D / 3, rx: 0, ry: 0, kind: 'end' });
} else {
for (let r = 0; r < 6; r++) pieces.push({ x: 0, y: 0, z: -(r + 1) * D * 0.6, rx: 0, ry: 0, kind: 'ring' });
pieces.push({ x: 0, y: 0, z: -7 * D * 0.6, rx: 0, ry: 0, kind: 'gate' });
}
return pieces;
}
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>
);
}
function ItemCard({ item, media, frame }: { item: TunnelItem; media?: React.ReactNode; frame?: boolean }) {
if (frame)
return (
<figure className="relative w-[min(78vw,56rem)] overflow-hidden rounded-2xl bg-surface shadow-[var(--shadow-lg)] ring-1 ring-border">
<div className="aspect-[16/10] [&>*]:size-full">{media}</div>
<figcaption className="dark absolute inset-x-0 bottom-0 flex items-end justify-between gap-4 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_86%,transparent),transparent)] p-5 pt-16 sm:p-7 sm:pt-20">
<span className="text-[clamp(1.1rem,0.9rem+0.8vw,1.6rem)] font-semibold text-foreground">{item.title}</span>
{item.meta && <span className="shrink-0 text-[14px] text-muted-foreground">{item.meta}</span>}
</figcaption>
</figure>
);
return (
<div className="w-[min(58vw,20rem)] overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
{media && <div className="aspect-[16/10] bg-surface [&>*]:size-full">{media}</div>}
<div className="p-5">
<div className="flex items-center gap-2.5">
{item.icon && <Icon name={item.icon} size={17} className="text-primary" />}
<h3 className="text-[16px] font-semibold text-foreground">{item.title}</h3>
</div>
{item.text && <p className="mt-2 text-[14px] leading-relaxed text-muted-foreground">{item.text}</p>}
</div>
</div>
);
}
function Figure({ item }: { item: TunnelItem }) {
return (
<div className="w-[min(86vw,30rem)] text-center">
<p className="text-[clamp(3.6rem,2rem+6vw,7.5rem)] font-semibold leading-none tracking-[-0.05em] text-foreground tabular-nums">{item.meta}</p>
<p className="mt-3 text-[18px] font-semibold text-foreground">{item.title}</p>
{item.text && <p className="mt-1.5 text-[15px] text-muted-foreground">{item.text}</p>}
</div>
);
}
/* ------------------------------------------------------------------ */
/** A zoom through a tunnel of cards, frames, rings or light while the page scrolls. */
export function TunnelZoom({ variant = 'cards', eyebrow, title, description, items, media = [], closing, action, className, onFocus, ...rest }: TunnelZoomProps) {
const live = useLive();
const els = useRef<(HTMLElement | null)[]>([]);
const stage = useRef<HTMLDivElement | null>(null);
const pieces = useRef<Piece[]>([]);
const last = useRef(0);
const n = items.length;
const pic = (i: number) => (media.length ? media[i % media.length] : undefined);
const apply = useCallback(
(p: number) => {
last.current = p;
const list = pieces.current;
if (!list.length) return;
const far = -list[list.length - 1].z;
// The camera ends just in front of the last piece
const stop = variant === 'portal' ? far - 120 : far;
const cam = smooth(span(p, 0.03, 0.97)) * stop;
list.forEach((pc, i) => {
const el = els.current[i];
if (!el) return;
let z = pc.z + cam;
const win = variant === 'rings' ? [-D * 0.85, -D * 0.3, P * 0.04, P * 0.22] : variant === 'frames' ? [-D * 1.25, -D * 0.45, P * 0.06, P * 0.34] : [-D * 1.8, -D * 0.6, P * 0.2, P * 0.6];
let o = Math.min(span(z, win[0], win[1]), 1 - span(z, win[2], win[3]));
let scale = 1;
if (pc.kind === 'head') o = 1 - span(p, 0.02, 0.12);
if (pc.kind === 'end') o = span(p, 0.84, 0.97);
if (pc.kind === 'ring') o = Math.min(span(z, -D * 2, -D * 0.5), 1 - span(z, P * 0.05, P * 0.3)) * 0.9;
if (pc.kind === 'gate') {
// The window opens to cover the stage once the camera arrives
const open = smooth(span(p, 0.72, 0.96));
scale = 1 + open * 0.7;
z = Math.min(z, 0);
o = span(z, -D * 3, -D * 0.6);
}
if (pc.kind === 'end' || pc.kind === 'head') z = 0;
el.style.transform = `translate(-50%, -50%) translate3d(${round(pc.x, 1)}px, ${round(pc.y, 1)}px, ${round(Math.min(z, P * 0.72), 1)}px) rotateX(${pc.rx}deg) rotateY(${pc.ry}deg) scale(${round(scale, 3)})`;
el.style.opacity = String(round(Math.max(0, o), 3));
el.style.visibility = o <= 0.002 ? 'hidden' : '';
});
// The picture behind the gate takes the whole window as a flat layer, only its opacity moves
const full = els.current[list.length + 1];
if (full) {
const f = smooth(span(p, 0.8, 0.95));
full.style.opacity = String(round(f, 3));
full.style.visibility = f <= 0.002 ? 'hidden' : '';
}
const over = els.current[list.length];
if (over) {
const x = smooth(span(p, 0.86, 0.98));
over.style.opacity = String(round(x, 3));
over.style.transform = `translate3d(0, ${round(24 * (1 - x), 1)}px, 0)`;
over.style.visibility = x <= 0.002 ? 'hidden' : '';
}
},
[variant],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
useEffect(() => {
if (!live) return;
const el = stage.current;
if (!el) return;
const measure = () => {
pieces.current = layout(variant, n, el.clientWidth, el.clientHeight);
apply(last.current);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [live, apply, variant, n]);
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>
);
const closingEl = (
<div className="w-[min(90vw,38rem)] text-center">
{closing && (
<>
<h3 className="text-balance text-h2 font-semibold text-foreground">{closing.title}</h3>
{closing.text && <p className="mx-auto mt-4 max-w-[32rem] text-pretty text-lead text-muted-foreground">{closing.text}</p>}
</>
)}
{actionEl && <div className="mt-8 flex justify-center">{actionEl}</div>}
</div>
);
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>
{variant === 'portal' ? (
<div className="relative mx-auto mt-12 aspect-square w-[min(100%,28rem)] overflow-hidden rounded-full shadow-[0_0_0_10px_color-mix(in_oklab,var(--primary)_16%,transparent),0_0_0_24px_color-mix(in_oklab,var(--primary)_8%,transparent)] [&>*]:size-full">{pic(0)}</div>
) : (
<ul className={cn('mt-12 grid grid-cols-1 justify-items-center gap-6 sm:mt-16', variant === 'frames' ? '' : 'sm:grid-cols-2 lg:grid-cols-3')}>
{items.map((it, i) => (
<li key={i}>{variant === 'rings' ? <Figure item={it} /> : <ItemCard item={it} media={pic(i)} frame={variant === 'frames'} />}</li>
))}
</ul>
)}
<div className="mt-16 flex justify-center">{closingEl}</div>
</div>
</section>
);
}
const count = layout(variant, n, 1280, 800).length;
const onSceneFocus = (e: React.FocusEvent<HTMLElement>) => {
onFocus?.(e);
if (els.current[count - 1]?.contains(e.target as Node) || els.current[count]?.contains(e.target as Node)) scrollToProgress(track.current, 0.99);
};
let item = 0;
return (
<section {...rest} onFocus={onSceneFocus} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${round((Math.max(3, n) * 0.85 + 1.4) * 100, 1)}svh` }}>
<div ref={stage} data-clip-ok className="sticky top-0 h-svh overflow-clip [perspective:900px]">
<span aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_10%,transparent),transparent)]" />
<div className="absolute inset-0 [transform-style:preserve-3d]">
{layout(variant, n, 1280, 800).map((pc, i) => {
const set = (el: HTMLElement | null) => void (els.current[i] = el);
const base = 'absolute left-1/2 top-1/2 will-change-transform';
if (pc.kind === 'head')
return (
<div key={i} ref={set} className={cn(base, 'w-[min(92vw,46rem)]')}>
<Head eyebrow={eyebrow} title={title} description={description} />
</div>
);
if (pc.kind === 'end')
return (
<div key={i} ref={set} className={base} style={{ opacity: 0 }}>
{closingEl}
</div>
);
if (pc.kind === 'ring')
return (
<span
key={i}
ref={set}
aria-hidden
className={cn(base, variant === 'portal' ? 'aspect-square w-[min(80vw,34rem)] rounded-full border-2' : 'h-[min(70svh,34rem)] w-[min(88vw,58rem)] rounded-[2.5rem] border')}
style={{ opacity: 0, borderColor: `color-mix(in oklab, var(--primary) ${variant === 'portal' ? 55 : 32}%, transparent)` }}
/>
);
if (pc.kind === 'gate')
return (
<div key={i} ref={set} aria-hidden className={cn(base, 'aspect-square w-[min(72vw,30rem)] overflow-hidden rounded-full shadow-[0_0_60px_color-mix(in_oklab,var(--primary)_40%,transparent)] [&>*]:size-full')} style={{ opacity: 0 }}>
{pic(0)}
</div>
);
const k = item++;
return (
<div key={i} ref={set} className={base} style={{ opacity: 0 }}>
{variant === 'rings' ? <Figure item={items[k]} /> : <ItemCard item={items[k]} media={pic(k)} frame={variant === 'frames'} />}
</div>
);
})}
</div>
{variant === 'portal' && (
<div ref={(el) => void (els.current[count + 1] = el)} aria-hidden className="absolute inset-0 [&>*]:size-full" style={{ opacity: 0, visibility: 'hidden' }}>
{pic(0)}
</div>
)}
{variant === 'portal' && (
<div ref={(el) => void (els.current[count] = el)} className="dark absolute inset-0 flex items-end justify-center bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_92%,transparent),color-mix(in_oklab,var(--background)_74%,transparent)_40%,transparent_78%)] px-6 pb-[clamp(2rem,10svh,6rem)]" style={{ opacity: 0, visibility: 'hidden' }}>
{closingEl}
</div>
)}
</div>
</div>
</section>
);
}Frequent questions
How do I install Tunnel zoom?
With aiw add @ai-website/tunnel-zoom. The command puts components/ui/tunnel-zoom.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 Tunnel zoom have?
4 variants, Tunnel of cards, Frame after frame, Rings with figures and Gate of light. You pick one with the variant prop, for example variant="frames".
Can an AI assistant build with Tunnel zoom?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tunnel zoom with aiw_components and install it with aiw_add @ai-website/tunnel-zoom. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.