Tunnel-Zoom beim Scrollen
Ein Zoom durch einen Tunnel beim Scrollen, ausgekleidet mit Karten, gerahmten Bildern, Ringen mit Zahlen oder einem Tor aus Licht. Die Sektion bleibt stehen, die Kamera fährt, am Ende wartet ein Schlusstext.
Die Kamera fährt, die Section bleibt
Ein Tunnel-Zoom hält die Section fest, während die Kamera hindurchfährt. Die Wände sind mit deinen Inhalten ausgekleidet, und am Ende wartet ein Schlusstext, der Effekt führt also irgendwohin.
„Tunnel aus Karten“ kleidet Wände, Boden und Decke mit Karten aus, am Handy stehen sie an den beiden Wänden. „Rahmen um Rahmen“ stellt gerahmte Bilder hintereinander, jedes füllt beim Ankommen das Fenster, die Bildunterschrift steht auf ruhigem Grund. „Ringe mit Zahlen“ führt runde Ringe im Akzent in die Ferne, in jedem eine Zahl, wie Meilensteine an einer Straße. „Tor aus Licht“ führt durch Lichtringe zu einem runden Fenster, dessen Bild sich auf das ganze Fenster öffnet.
Das Tor aus Licht enthüllt ein Produkt oder einen Ort, die Ringe tragen Meilensteine und Zahlen. Halte Karten kurz, sie ziehen schnell vorbei. Schreib den Schlusstext als nächsten Schritt, dort endet der Scroll.
Variants
Details
Was Tunnel-Zoom mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- 3D-Szenen
- Bereich
- Visuals
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/tunnel-zoom.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 TunnelZoomProps aus components/ui/tunnel-zoom.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalTunnelZoomVariantcardsframesringsportal
eyebrowoptionalstringtitlePflichtReact.ReactNodedescriptionoptionalstringitemsPflichtTunnelItem[]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 }
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).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 314 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 { 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>
);
}Häufige Fragen
Wie installiere ich Tunnel-Zoom?
Mit aiw add @ai-website/tunnel-zoom. Der Befehl legt die Datei components/ui/tunnel-zoom.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 Tunnel-Zoom?
4 Varianten, Tunnel aus Karten, Rahmen um Rahmen, Ringe mit Zahlen und Tor aus Licht. Du wählst sie mit der Prop variant, zum Beispiel variant="frames".
Kann ein KI-Assistent Tunnel-Zoom einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Tunnel-Zoom mit aiw_components und installieren sie mit aiw_add @ai-website/tunnel-zoom. Nenn im Prompt die Variante, etwa variant="cards", dann schreibt der Assistent deine Inhalte direkt in die Props.