Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 6 Components in 3D-Szenen

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

Tunnel aus KartenNeu in 1.2

Karten kleiden Wände, Boden und Decke eines Tunnels aus und ziehen an allen Seiten vorbei. Auf dem Telefon stehen sie an den zwei Wänden, am Ende wartet der Schlusstext.

Rahmen um RahmenNeu in 1.2

Gerahmte Bilder stehen hintereinander. Jedes füllt das Fenster, wenn die Kamera ankommt, und zieht vorbei, die Bildunterschrift auf ruhigem Schatten.

Ringe mit ZahlenNeu in 1.2

Abgerundete Ringe laufen im Akzent in die Ferne. In jedem steht eine Zahl und zieht vorbei, wie Meilensteine an einer Straße.

Tor aus LichtNeu in 1.2

Lichtringe führen zu einem runden Fenster. Wenn die Kamera ankommt, öffnet sich das Bild dahinter auf das ganze Fenster und der Schlusstext steigt auf dunklem Schatten auf.

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
Bringt mit
utilskitsectionbuttoniconrevealscroll-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

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.

  • variantoptional
    TunnelZoomVariant

    cardsframesringsportal

  • eyebrowoptional
    string
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    string
  • itemsPflicht
    TunnelItem[]
  • mediaoptional
    React.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
components/ui/tunnel-zoom.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert