Skip to content
UI Kit
Accent color

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.

5 variantsOne of 6 components in 3D scenes

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

Extruded wordNew in 1.2

One large word with a solid body in the accent behind it. It turns from left to right while the section passes and leans toward the mouse.

Lines at different depthsNew in 1.2

A headline whose lines float at different depths. While the section scrolls by they drift apart sideways and come together in the middle.

Headline that stands upNew in 1.2

The lines lie flat like a floor and stand up one after another as the section scrolls into view, the last one in the accent.

Echo into the distanceNew in 1.2

A word repeated as outlines into the distance. While scrolling the echoes close in on the solid front word.

Story that runs awayNew in 1.2

A short story on a tilted plane that runs up and away into the distance while the section stays. The whole text is one heading and paragraphs in the page.

Details

What Depth text brings along, read from the registry and the source.

Category
3D scenes
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

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

  • variantoptional
    DepthTextVariant

    extrudelayersriseechocrawl

  • eyebrowoptional
    string
  • textrequired
    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

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

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.

Often searched

Jump to

Copied to the clipboard