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.
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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
variantoptionalDepthTextVariantextrudelayersriseechocrawl
eyebrowoptionalstringtextPflichtstring | string[]The large type. layers and rise: one line per entry (use \n in the string or an array).
descriptionoptionalstringstoryoptionalstring[]crawl: the paragraphs that run away
actionoptional{ label: string; href: string }depthoptionalnumberextrude, 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
'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.