3D text effect on scroll
Type in 3D layers that turn, rise and come apart with the scroll. The words stay one real heading, the depth comes from copies hidden from screen readers. Without JavaScript the finished picture stands.
Type with depth, still one heading
Depth text gives a headline 3D layers that turn, rise and come apart with the scroll. The words stay one real heading, the depth comes from copies hidden from screen readers, so the sentence is read once. Without JavaScript the finished picture stands.
Extruded word gives one large word a solid body in the accent that turns while the section passes and leans toward the mouse. Lines at different depths let the lines of a headline drift apart sideways and meet in the middle. Headline that stands up lays the lines flat like a floor and raises them one after another. Echo into the distance repeats a word as outlines that close in on the solid front word. Story that runs away sets a short text on a tilted plane that runs off into the distance.
Use depth text for one or two words that carry the page, longer text belongs in the story version. Pick a bold weight, it keeps its body in 3D. Leave room around it, depth needs space to read.
Variants
Details
What Depth text brings along, read from the registry and the source.
- Category
- 3D scenes
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/depth-text.tsx
- Brings along
- utilskitsectionbuttonrevealscroll-scenedepth
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- In collections
- 3D and motion
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DepthTextProps interface from components/ui/depth-text.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalDepthTextVariantextrudelayersriseechocrawl
eyebrowoptionalstringtextrequiredstring | 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
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
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, 246 lines
'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>
);
}Frequent questions
How do I install Depth text?
With aiw add @ai-website/depth-text. The command puts components/ui/depth-text.tsx into your project, the code is yours from then on. It brings along section, button, reveal, scroll-scene and depth. It needs no further npm packages.
Which variants does Depth text have?
5 variants, Extruded word, Lines at different depths, Headline that stands up, Echo into the distance and Story that runs away. You pick one with the variant prop, for example variant="layers".
Can an AI assistant build with Depth text?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Depth text with aiw_components and install it with aiw_add @ai-website/depth-text. Name the variant in your prompt, for example variant="extrude", and the assistant writes your content straight into the props.