Zum Inhalt springen
UI Kit
Accent

3D Text Effekt beim Scrollen

Schrift in 3D-Ebenen, die sich mit dem Scroll dreht, aufrichtet und auseinandergeht. Die Wörter bleiben eine echte Überschrift, die Tiefe kommt aus Kopien, die Screenreader nicht sehen. Ohne JavaScript steht das fertige Bild.

5 VariantsEine von 6 Components in 3D-Szenen

Schrift mit Tiefe, weiter eine Überschrift

Die Tiefenschrift gibt einer Überschrift 3D-Ebenen, die sich mit dem Scroll drehen, aufrichten und auseinandergehen. Die Wörter bleiben eine echte Überschrift, die Tiefe kommt aus Kopien, die Screenreader nicht sehen, der Satz wird also einmal gelesen. Ohne JavaScript steht das fertige Bild.

Das „Plastische Wort“ gibt einem großen Wort einen festen Körper im Akzent, der sich beim Vorbeiscrollen dreht und zur Maus neigt. „Zeilen in eigener Tiefe“ lassen die Zeilen einer Headline seitlich auseinanderdriften und in der Mitte zusammenkommen. „Überschrift, die aufsteht“ legt die Zeilen flach wie einen Boden und richtet sie nacheinander auf. „Echo in die Ferne“ wiederholt ein Wort als Umriss, die Echos rücken an das feste Wort heran. „Geschichte, die davonläuft“ setzt einen kurzen Text auf eine gekippte Fläche, die in die Ferne läuft.

Nimm die Tiefenschrift für ein oder zwei Wörter, die die Seite tragen, längerer Text gehört in die Geschichte. Wähl einen kräftigen Schnitt, dann behält die Schrift in 3D ihren Körper. Lass Raum drumherum, Tiefe braucht Platz.

Variants

Plastisches WortNeu in 1.2

Ein großes Wort mit festem Körper im Akzent dahinter. Es dreht sich beim Vorbeiscrollen von links nach rechts und neigt sich zur Maus.

Zeilen in eigener TiefeNeu in 1.2

Eine Überschrift, deren Zeilen in unterschiedlicher Tiefe schweben. Beim Vorbeiscrollen driften sie seitlich auseinander und finden in der Mitte zusammen.

Überschrift, die aufstehtNeu in 1.2

Die Zeilen liegen flach wie ein Boden und richten sich nacheinander auf, während die Sektion ins Bild scrollt, die letzte im Akzent.

Echo in die FerneNeu in 1.2

Ein Wort, als Umriss in die Ferne wiederholt. Beim Scrollen rücken die Echos an das feste Wort vorn heran.

Geschichte, die davonläuftNeu in 1.2

Eine kurze Geschichte auf einer geneigten Fläche, die nach oben in die Ferne läuft, während die Sektion stehen bleibt. Der ganze Text ist eine Überschrift mit Absätzen in der Seite.

Details

Was Tiefenschrift mitbringt, aus Registry und Quellcode gelesen.

Kategorie
3D-Szenen
Bereich
Visuals
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/depth-text.tsx
Bringt mit
utilskitsectionbuttonrevealscroll-scenedepth
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen
In Sammlungen
3D und Bewegung

Props

Die Schnittstelle DepthTextProps aus components/ui/depth-text.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
    DepthTextVariant

    extrudelayersriseechocrawl

  • eyebrowoptional
    string
  • textPflicht
    string | string[]

    The large type. layers and rise: one line per entry (use \n in the string or an array).

  • descriptionoptional
    string
  • storyoptional
    string[]

    crawl: the paragraphs that run away

  • actionoptional
    { label: string; href: string }
  • depthoptional
    number

    extrude, echo: how deep the body or the echoes reach, 1 is the default

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

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, 246 Zeilen
components/ui/depth-text.tsx
'use client';

import { useCallback, useRef } from 'react';
import { cn, round } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Reveal } from '@/components/ui/reveal';
import { seg, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
import { usePointerTilt } from '@/components/ui/depth';

/*
 * Type in 3D layers. The words stay real text (one heading for search and screen readers), the
 * depth comes from copies behind them that are hidden from assistive technology. The scroll turns,
 * raises or pulls the layers apart through one CSS variable, so the browser only recomposites.
 *
 *   extrude  one large word with a solid body behind it, turning with the scroll and the pointer
 *   layers   a headline whose lines float at different depths and drift apart while scrolling
 *   rise     a headline lying flat like a floor that stands up as it scrolls into view
 *   echo     a word repeated into the distance, the echoes close in on the front word
 *   crawl    a short story that runs up and away on a tilted plane while the section stays
 *
 * Without JavaScript and with reduced motion every variant shows its finished, upright picture.
 */

export type DepthTextVariant = 'extrude' | 'layers' | 'rise' | 'echo' | 'crawl';

export interface DepthTextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
  variant?: DepthTextVariant;
  eyebrow?: string;
  /** The large type. layers and rise: one line per entry (use \n in the string or an array). */
  text: string | string[];
  description?: string;
  /** crawl: the paragraphs that run away */
  story?: string[];
  action?: { label: string; href: string };
  /** extrude, echo: how deep the body or the echoes reach, 1 is the default */
  depth?: number;
}

const lines = (text: string | string[]) => (Array.isArray(text) ? text : text.split('\n')).filter(Boolean);

function Foot({ description, action, center = true }: Pick<DepthTextProps, 'description' | 'action'> & { center?: boolean }) {
  if (!description && !action) return null;
  return (
    <Reveal className={cn('relative z-10 mt-10 max-w-[36rem]', center && 'mx-auto text-center')}>
      {description && <p className="text-pretty text-lead text-muted-foreground">{description}</p>}
      {action && (
        <div className={cn('mt-7 flex max-sm:[&>span]:w-full', center && 'justify-center')}>
          <Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
            {action.label}
          </Button>
        </div>
      )}
    </Reveal>
  );
}

const BIG = 'font-semibold leading-[0.9] tracking-[-0.055em]';

/** Type in 3D layers that turn, rise and come apart with the scroll. */
export function DepthText({ variant = 'extrude', eyebrow, text, description, story = [], action, depth = 1, className, ...rest }: DepthTextProps) {
  const live = useLive();
  const rows = lines(text);
  const words = rows.join(' ');
  const tilt = usePointerTilt<HTMLDivElement>({ ease: 0.08, focus: false });
  const crawlEl = useRef<HTMLDivElement | null>(null);
  const onCrawl = useCallback((p: number) => {
    const el = crawlEl.current;
    if (el) el.style.transform = `rotateX(58deg) translate3d(0, ${round(60 - p * 175, 2)}%, 0)`;
  }, []);
  const scene = useSceneProgress<HTMLElement>(live, {
    mode: variant === 'rise' ? 'enter' : variant === 'crawl' ? 'pin' : 'pass',
    onProgress: variant === 'crawl' ? onCrawl : undefined,
    self: true,
  });
  // On the server, without JavaScript and with reduced motion, --p is missing: every seg() is finished (1)
  const head = eyebrow && <Eyebrow className="relative z-10 text-center">{eyebrow}</Eyebrow>;

  /* ---------- extrude ---------- */
  if (variant === 'extrude') {
    const n = Math.round(14 * depth);
    // Turn from the left to the right while the section passes, plus a few degrees toward the pointer
    const turn = `rotateX(calc(12deg - ${seg(0, 1)} * 16deg + var(--tilt-y, 0) * -6deg)) rotateY(calc(-34deg + ${seg(0, 1)} * 68deg + var(--tilt-x, 0) * 10deg))`;
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          {head}
          <div ref={tilt.ref} {...tilt.handlers} className="relative mx-auto flex justify-center py-6 [perspective:1200px] sm:py-10">
            <div className="relative [transform-style:preserve-3d]" style={{ transform: live ? turn : 'rotateX(10deg) rotateY(-16deg)' }}>
              {Array.from({ length: n }, (_, i) => (
                <span
                  key={i}
                  aria-hidden
                  className={cn('absolute inset-0 select-none whitespace-nowrap text-center text-[clamp(3.4rem,0.5rem+13vw,14rem)]', BIG)}
                  style={{
                    transform: `translateZ(${round(-(i + 1) * 4.5, 1)}px)`,
                    color: `color-mix(in oklab, var(--primary) ${round(70 - (i / n) * 40)}%, var(--background))`,
                  }}
                >
                  {words}
                </span>
              ))}
              <h2 className={cn('relative whitespace-nowrap text-center text-[clamp(3.4rem,0.5rem+13vw,14rem)] text-foreground', BIG)}>{words}</h2>
            </div>
          </div>
          <Foot description={description} action={action} />
        </div>
      </section>
    );
  }

  /* ---------- layers ---------- */
  if (variant === 'layers') {
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          {head}
          <div className="relative mx-auto [perspective:900px]">
            <h2 className={cn('relative text-center text-[clamp(3rem,1rem+8.5vw,9.5rem)] text-foreground [transform-style:preserve-3d]', BIG)} style={{ transform: 'rotateX(calc(16deg - var(--p, 0.5) * 32deg))' }}>
              {rows.map((r, i) => {
                const k = i - (rows.length - 1) / 2;
                return (
                  <span
                    key={i}
                    className={cn('block', i % 2 === 1 && 'kit-accent-text')}
                    style={{ transform: `translate3d(calc((var(--p, 0.5) - 0.5) * ${round(k * 190, 1)}px), 0, calc((0.5 - var(--p, 0.5)) * ${round(-Math.abs(k) * 260 + 80, 1)}px))` }}
                  >
                    {r}
                  </span>
                );
              })}
            </h2>
          </div>
          <Foot description={description} action={action} />
        </div>
      </section>
    );
  }

  /* ---------- rise ---------- */
  if (variant === 'rise') {
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-x-clip py-20 sm:py-28', className)}>
        <div className="kit-container">
          {head}
          <div className="relative mx-auto [perspective:700px] [perspective-origin:50%_100%]">
            <h2 className={cn('relative text-center text-[clamp(3rem,1rem+8vw,9rem)] text-foreground [transform-style:preserve-3d]', BIG)}>
              {rows.map((r, i) => (
                <span
                  key={i}
                  className="block origin-bottom"
                  style={{
                    transform: `rotateX(calc((1 - ${seg(i * 0.12, 0.62 + i * 0.12)}) * 78deg))`,
                    opacity: `calc(0.25 + ${seg(i * 0.12, 0.5 + i * 0.12)} * 0.75)`,
                  }}
                >
                  <span className={cn(i === rows.length - 1 && 'kit-accent-text')}>{r}</span>
                </span>
              ))}
            </h2>
            {/* the floor line the words stand up from */}
            <span aria-hidden className="mx-auto mt-4 block h-px w-[min(100%,40rem)] bg-gradient-to-r from-transparent via-border-strong to-transparent" />
          </div>
          <Foot description={description} action={action} />
        </div>
      </section>
    );
  }

  /* ---------- echo ---------- */
  if (variant === 'echo') {
    const n = Math.round(7 * depth);
    return (
      <section {...rest} ref={scene} className={cn('relative overflow-x-clip py-24 sm:py-32', className)}>
        <div className="kit-container">
          {head}
          <div className="relative mx-auto grid place-items-center pt-24 [perspective:800px]">
            <div className="grid place-items-center [transform-style:preserve-3d]" style={{ transform: 'rotateX(18deg)' }}>
              {Array.from({ length: n }, (_, i) => (
                <span
                  key={i}
                  aria-hidden
                  className={cn('[grid-area:1/1] select-none whitespace-nowrap text-[clamp(3.4rem,1rem+10vw,11rem)] text-transparent', BIG)}
                  style={{
                    WebkitTextStroke: '1.5px color-mix(in oklab, var(--primary) 75%, transparent)',
                    transform: `translate3d(0, ${round(-(i + 1) * 24, 1)}px, calc(${round(-(i + 1) * 110, 1)}px * (1 - ${seg(0.15, 0.6)} * 0.7)))`,
                    opacity: round(0.7 - (i / n) * 0.6, 3),
                  }}
                >
                  {words}
                </span>
              ))}
              <h2 className={cn('relative [grid-area:1/1] whitespace-nowrap text-[clamp(3.4rem,1rem+10vw,11rem)] text-foreground', BIG)}>{words}</h2>
            </div>
          </div>
          <Foot description={description} action={action} />
        </div>
      </section>
    );
  }

  /* ---------- crawl ---------- */
  if (!live) {
    return (
      <section {...rest} ref={scene} className={cn('relative py-20 sm:py-28', className)}>
        <div className="kit-container max-w-[44rem] text-center">
          {head}
          <h2 className={cn('text-balance text-h1 text-foreground', 'font-semibold')}>{words}</h2>
          <div className="mt-8 grid gap-5 text-pretty text-lead text-muted-foreground">
            {story.map((s, i) => (
              <p key={i}>{s}</p>
            ))}
          </div>
          <Foot action={action} />
        </div>
      </section>
    );
  }
  return (
    <section {...rest} className={cn('relative overflow-x-clip', className)}>
      <div ref={scene as never} className="relative h-[320svh]">
        <div data-clip-ok className="sticky top-0 h-svh overflow-clip">
          <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[34%] bg-gradient-to-b from-background via-background to-transparent" />
          <div className="absolute inset-x-0 bottom-0 top-[18%] flex justify-center [perspective:420px] [perspective-origin:50%_20%]">
            <div ref={crawlEl} className="w-[min(76vw,40rem)] origin-[50%_100%] text-center will-change-transform" style={{ transform: 'rotateX(58deg) translate3d(0, 60%, 0)' }}>
              {head}
              <h2 className={cn('text-[clamp(2.4rem,1.4rem+4vw,4.8rem)] text-foreground', BIG)}>{words}</h2>
              <div className="mt-10 grid gap-8 text-pretty text-[clamp(1.25rem,1rem+1vw,1.75rem)] font-medium leading-snug text-foreground">
                {story.map((s, i) => (
                  <p key={i}>{s}</p>
                ))}
              </div>
            </div>
          </div>
        </div>
      </div>
      {action && (
        <div className="kit-container flex justify-center pb-20 max-sm:[&>span]:w-full sm:pb-28">
          <Button href={action.href} icon="arrow-right" magnetic className="max-sm:w-full">
            {action.label}
          </Button>
        </div>
      )}
    </section>
  );
}

Häufige Fragen

Wie installiere ich Tiefenschrift?

Mit aiw add @ai-website/depth-text. Der Befehl legt die Datei components/ui/depth-text.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind section, button, reveal, scroll-scene und depth. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Tiefenschrift?

5 Varianten, Plastisches Wort, Zeilen in eigener Tiefe, Überschrift, die aufsteht, Echo in die Ferne und Geschichte, die davonläuft. Du wählst sie mit der Prop variant, zum Beispiel variant="layers".

Kann ein KI-Assistent Tiefenschrift einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Tiefenschrift mit aiw_components und installieren sie mit aiw_add @ai-website/depth-text. Nenn im Prompt die Variante, etwa variant="extrude", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert