Skip to content
UI Kit
Accent color

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.

4 variantsOne of 6 components in 3D scenes

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

Tunnel of cardsNew in 1.2

Cards line the walls, floor and ceiling of a tunnel and pass by on every side. On phones they stand on the two walls, the closing text waits at the end.

Frame after frameNew in 1.2

Framed pictures stand one behind the other. Each fills the window as the camera arrives and passes, its caption on a calm shade.

Rings with figuresNew in 1.2

Rounded rings run into the distance in the accent. A figure stands in each one and passes by, like milestones on a road.

Gate of lightNew in 1.2

Rings of light lead to a round window. As the camera arrives the picture behind it opens to the full window and the closing text rises on a dark shade.

Details

What Tunnel zoom 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/tunnel-zoom.tsx
Brings along
utilskitsectionbuttoniconrevealscroll-scene
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

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

  • variantoptional
    TunnelZoomVariant

    cardsframesringsportal

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode
  • descriptionoptional
    string
  • itemsrequired
    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 }

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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard