Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

ThatPainter is reader-supported. When you buy through links on our site, we may earn an affiliate commission. Learn More

CSS layered text can respond to a pointer without turning the heading into an image or a 3D model. Keep one real, semantic heading in the document, place decorative copies behind it, and use CSS transforms to create the depth illusion. A small JavaScript controller can then map pointer position to a restrained tilt or bulge. The result is best treated as an enhancement: the text should stay readable and complete when motion is disabled, a pointer is unavailable, or the device is under load.

What makes layered text look three-dimensional?

CSS does not extrude the outlines of letters into solid geometry. It transforms flat rendered elements in a three-dimensional coordinate system. A stack of repeated text copies, viewed through perspective and separated along the X, Y, or Z axis, supplies cues that suggest depth. The CSS Transforms specification describes transformed elements as two-dimensional planes positioned in 3D space (CSS Transforms Module Level 2).

It helps to distinguish four related ideas:

  • Static depth: Offset text copies create an extrusion-like edge.
  • Motion: A transition or animation changes the stack over time.
  • Interactivity: User input changes the effect while it is happening.
  • Dynamicism: The effect adapts sensibly to input, screen size, content, device capabilities, and motion preferences.

For a short display heading, layered DOM text is usually simpler than building a 3D mesh. It remains real, selectable text, and it can fall back to ordinary typography. It is a poor fit for body copy, long navigation labels, or any text that must remain effortless to read while moving.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Start with one semantic heading and decorative copies

Keep the accessible text in a real heading. Mark the repeated copies as decorative so assistive technology does not announce the same word over and over:

#1 Best Overall
<div class="scene">
  <h1 class="hero-title">
    <span class="hero-title__front">DEPTH</span>
    <span class="hero-title__layers" aria-hidden="true">
      <span class="hero-title__layer" style="--i: 1">DEPTH</span>
      <span class="hero-title__layer" style="--i: 2">DEPTH</span>
      <span class="hero-title__layer" style="--i: 3">DEPTH</span>
    </span>
  </h1>
</div>

The front copy is the meaningful heading; the layers only provide visual depth. Do not make a decorative copy the only focusable or interactive element. Real text is more adaptable to assistive technology and user styling than text embedded in an image (W3C technique C22: using CSS to control visual presentation of text).

Build a restrained static stack

Apply perspective to the scene and preserve the shared 3D context on the layer container. Perspective controls foreshortening: smaller values exaggerate it, while larger values flatten it. There is no universally correct value; start around 600px and adjust to the size and composition of the heading.

.scene {
  --layer-x: 0.7px;
  --layer-y: 0.7px;
  --layer-z: 1px;
  position: relative;
  perspective: 600px;
  isolation: isolate;
}

.hero-title {
  position: relative;
  display: inline-block;
  margin: 0;
  font-size: clamp(3rem, 12vw, 10rem);
  line-height: 0.9;
  letter-spacing: -0.06em;
}

.hero-title__front,
.hero-title__layer {
  display: block;
  white-space: nowrap;
}

.hero-title__layers {
  position: absolute;
  inset: 0;
  pointer-events: none;
  transform-style: preserve-3d;
}

.hero-title__layer {
  position: absolute;
  inset: 0;
  color: rgb(255 255 255 / 35%);
  transform: translate3d(
    calc(var(--i) * var(--layer-x)),
    calc(var(--i) * var(--layer-y)),
    calc(var(--i) * var(--layer-z))
  );
}

.hero-title__front {
  position: relative;
  z-index: 1;
  color: white;
  text-shadow: 0 0.02em 0.04em rgb(0 0 0 / 35%);
}

The `–i` value gives each copy a different offset. A stack of roughly 8–24 layers can be a useful starting range for a display title, but add copies only while they visibly improve the result. `transform-style: preserve-3d` keeps descendants in a shared 3D context; without it, nested depth can appear flat. For more control, `perspective-origin` changes the vanishing point, `transform-origin` changes the pivot, and `backface-visibility: hidden` can help if a face rotates far enough to show its reverse. These are rendering controls, not geometry generators (CSS Transforms Module Level 2).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Generate layers when the text or count changes

For a fixed short title, writing the copies in markup is fine. If several headings share the effect, or the content changes, generate decorative layers from the semantic element’s text. Copy text content rather than arbitrary HTML, so decorative copies do not duplicate links, emphasis, or other nested semantics.

function createLayers(element, count = 12) {
  const text = element.textContent.trim();
  const front = document.createElement("span");
  const layers = document.createElement("span");

  front.className = "hero-title__front";
  front.textContent = text;
  layers.className = "hero-title__layers";
  layers.setAttribute("aria-hidden", "true");

  for (let index = 1; index <= count; index++) {
    const layer = document.createElement("span");
    layer.className = "hero-title__layer";
    layer.style.setProperty("--i", index);
    layer.textContent = text;
    layers.append(layer);
  }

  element.replaceChildren(front, layers);
}

Call this only when appropriate for the element, and preserve the heading itself rather than replacing it with a generic container. If application state, localization, or user input changes the title later, update both the front copy and decorative layers. A font swap can also change the text’s dimensions; use the same typography rules on every copy and avoid relying on stale measurements.

Map pointer position to a gentle tilt

For a pointer-following effect, convert pointer coordinates inside the scene to a normalized range from −1 to 1. Store those as targets, then ease the displayed values toward them in `requestAnimationFrame`. This avoids making the title twitch with every raw event.

const scene = document.querySelector(".scene");
let targetX = 0;
let targetY = 0;
let currentX = 0;
let currentY = 0;
let frameRequested = false;

function updateTarget(event) {
  const bounds = scene.getBoundingClientRect();
  if (!bounds.width || !bounds.height) return;

  targetX = Math.max(-1, Math.min(1,
    ((event.clientX - bounds.left) / bounds.width) * 2 - 1
  ));
  targetY = Math.max(-1, Math.min(1,
    ((event.clientY - bounds.top) / bounds.height) * 2 - 1
  ));

  if (!frameRequested) {
    frameRequested = true;
    requestAnimationFrame(render);
  }
}

function render() {
  frameRequested = false;
  currentX += (targetX - currentX) * 0.12;
  currentY += (targetY - currentY) * 0.12;

  scene.style.setProperty("--pointer-x", currentX);
  scene.style.setProperty("--pointer-y", currentY);

  if (Math.abs(targetX - currentX) > 0.001 ||
      Math.abs(targetY - currentY) > 0.001) {
    frameRequested = true;
    requestAnimationFrame(render);
  }
}

scene.addEventListener("pointermove", updateTarget);
scene.addEventListener("pointerleave", () => {
  targetX = 0;
  targetY = 0;
  if (!frameRequested) {
    frameRequested = true;
    requestAnimationFrame(render);
  }
});

Use the normalized values in one composed transform:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
.hero-title__layers {
  transform:
    rotateX(calc(var(--pointer-y, 0) * -5deg))
    rotateY(calc(var(--pointer-x, 0) * 8deg));
  transition: transform 180ms ease-out;
}

Single-digit rotation angles are a sensible starting point. The front face should remain legible at every position. The JavaScript example reads the bounds on pointer movement; for an interaction with many elements or a particularly expensive page, cache dimensions and refresh them on resize rather than repeatedly measuring. Avoid writing layout properties in the movement handler.

Choose the kind of response

  • Tilt the whole stack: Stable and relatively simple. All the layers move as one object.
  • Shift each layer: Can create a swelling or “bulging” impression, but multiplies per-layer updates and can separate the lettering visually.
  • Move only the extrusion or shadow: More subtle and often easier to read.
  • Use different rates for front and back: Adds parallax, but excessive separation weakens the illusion.

For a bulge, add a small pointer contribution to each layer’s existing offset rather than replacing its transform:

.hero-title__layer {
  transform: translate3d(
    calc(var(--i) * var(--layer-x) + var(--pointer-x, 0) * var(--i) * 0.1px),
    calc(var(--i) * var(--layer-y) + var(--pointer-y, 0) * var(--i) * 0.1px),
    calc(var(--i) * var(--layer-z))
  );
}

Keep scene perspective, group tilt, and per-layer depth conceptually separate. A common bug is to define `transform` in multiple rules and accidentally replace an earlier transform. Compose the functions in one declaration or use nested wrappers, such as one for perspective, one for group tilt, and one for the depth stack.

CSS-only motion, scroll, and other inputs

If the interaction is simply a change on hover or focus, CSS may be enough:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
.scene:hover .hero-title__layers,
.scene:focus-within .hero-title__layers {
  transform: rotateX(-4deg) rotateY(6deg) translateZ(8px);
  transition: transform 400ms cubic-bezier(.2, .8, .2, 1);
}

Keyframes can animate layers automatically, but a perpetual loop is not a good default for a heading people are trying to read. A static depth treatment with a subtle response to deliberate input is often less distracting.

Scroll progress, pointer velocity, focus state, and device orientation are also possible inputs. They should not each write competing values to the same `transform`. Define a single state model, then derive the final CSS variables from it. Scroll-driven effects should be subtle and tested with the actual page layout; a title that changes as a user scrolls can distract from nearby content. Do not activate orientation sensors automatically. On touch screens, a static version is usually the simplest choice; alternatively, provide a deliberate control and honor any permission the platform requires.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Support touch, keyboard, and reduced motion

Hover is not a universal input. A useful gate for hover styling is a fine pointer with hover capability:

@media (hover: hover) and (pointer: fine) {
  .scene:hover .hero-title__layers {
    /* pointer-capable enhancement */
  }
}

.scene:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 0.25em;
}

Do not require dragging or pointer movement to discover the title. If the effect belongs to a control, make the semantic link or button—not a decorative layer—focusable, labeled, and usable by keyboard. A manual motion toggle can be useful where movement is prominent.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Honor the user’s reduced-motion preference by minimizing nonessential movement while preserving the static depth if it remains comfortable:

@media (prefers-reduced-motion: reduce) {
  .hero-title__layers {
    transform: none !important;
    transition: none !important;
    animation: none !important;
  }

  .hero-title__layer {
    transform: translate3d(
      calc(var(--i) * 0.35px),
      calc(var(--i) * 0.35px),
      0
    ) !important;
  }
}

The preference is `reduce`, and it is a request to reduce motion rather than a command that every visual change be removed (MDN: Using media queries for accessibility). If JavaScript continues writing pointer transforms, CSS alone may not be enough: stop or ignore the interaction when reduced motion is active, and respond if the preference changes.

Keep the effect responsive and resilient

  • Responsive type: Use the same font, line height, letter spacing, and wrapping behavior on the front and every layer. Long text should be allowed to wrap or should not use the effect.
  • Font loading: A late-loading font can make copies appear to drift. Avoid unnecessary dimension measurements; if you must measure, wait for fonts to settle and refresh on resize.
  • Changing content: Regenerate decorative copies whenever the semantic title changes. Do not leave the old text behind in hidden layers.
  • Offscreen content: Consider suspending interaction when the scene is not visible, especially on pages with several animated headings.
  • Fallback: The front semantic text should look intentional even if 3D transforms or JavaScript are unavailable.

If layers drift, confirm they share dimensions and typography, that their common parent is positioned correctly, and that no copy wraps differently. If everything looks flat, check the scene’s `perspective`, the stack’s `transform-style: preserve-3d`, and whether another CSS declaration replaced the transform.

Performance without unsupported promises

Transforms and opacity are generally preferable animation targets to layout-changing properties such as width, height, margin, or padding, but compositor-friendly does not mean cost-free. Layer count, text rendering, shadows, device hardware, and the rest of the page all matter. MDN’s performance guidance recommends favoring properties such as transforms and opacity where suitable, while noting that animations still consume resources (MDN: CSS performance).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Use only as many copies as the design needs.
  • Avoid continuously animating `text-shadow` across a large stack.
  • Coalesce visual updates with `requestAnimationFrame`.
  • Do not start a separate endless JavaScript loop for every heading.
  • Test on mobile hardware as well as a desktop browser.
  • Do not apply `will-change: transform` to every layer by default; excessive use can increase memory use.

There is no defensible universal frame-rate promise for this effect. A transform that feels smooth on one device can still stutter on another, particularly when many text layers or shadows are involved. Simplify the effect if it is distracting or slow.

When CSS layers are not enough

Need Good starting point Trade-off
One short decorative heading HTML, CSS, and optionally a small pointer controller Simple and semantic; depth remains an illusion.
Complex timelines or coordinated scroll animation GSAP or the Web Animations API Useful orchestration, but unnecessary for one pointer-to-variable mapping. GSAP states that its library is free for all users (GSAP pricing).
A designer-authored, stateful graphic Rive Supports interactive animation workflows, but text may no longer be ordinary DOM text; plan for accessibility and fallback (Rive plans and capabilities).
A broader interactive 3D scene Spline or a 3D runtime Offers a scene-oriented workflow; it is overkill for a single layered heading and may add delivery and accessibility complexity (Spline plans).
Actual extruded glyphs, materials, or lighting Canvas/WebGL or a 3D engine Provides real geometry and camera control, but increases runtime and fallback work.

A focused helper such as DepthText may save implementation effort for pointer-, scroll-, or orientation-reactive layered text. Its size and performance descriptions are vendor claims, not independent benchmarks; inspect its maintenance, license, browser behavior, and accessibility before adding it.

For a short title, native HTML and CSS with a small controller are usually enough. Choose a broader tool only when the project needs its broader animation or 3D capabilities.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.