← Back to the library

PROMPT3D & design

Brief: turn your logo text into floating 3D letters with three.js

The brief and build notes behind the dainer.ai hero: bevelled 3D letters extruded from your own font, placed exactly on the CSS text, floating, orbiting and landing back.

WHAT YOU’LL GET

A three.js wordmark that keeps your real heading for accessibility and search, falls back to plain text when WebGL or motion is off, and reads correctly in light and dark mode.

WHO IT’S FOR

Builders who want a 3D hero built with Claude Code or Codex without losing the real heading text.

DIFFICULTY

Intermediate

TIME

2–3 hours

WORKS WITH

Claude Codex

Get the full file

The whole resource as one Markdown file for your notes or your AI workspace.

FREE

What you will build

A hero where your logo text turns into solid, bevelled 3D letters. Each letter is extruded from your own brand font, sits exactly on top of the real CSS letter, floats in a gentle wave, launches into a tilted orbit, spins two full turns and lands back in order. The real heading stays in the page for search and screen readers, and it is the fallback whenever WebGL or motion is off.

These are the brief and build notes behind the dainer.ai homepage wordmark. Hand the brief to Claude Code or Codex, then use the steps to check its work.

Before you start

  • A page or React/Next.js app with a text heading. The three.js parts work on any page.
  • three.js installed (npm install three). This build used three 0.185.
  • Your font as a three.js typeface JSON file. The library guide Use your own font in three.js: convert it to typeface JSON shows how in about 15 minutes.
  • An AI coding agent (Claude Code or Codex) that can edit the project and run it locally.
  • Playwright, or another way to capture phone and desktop screenshots in light and dark mode.

How it works

The page keeps two layers. The bottom layer is the real <h1>, split into one <span> per letter. The top layer is a transparent WebGL canvas that covers the hero and ignores the mouse. When the 3D letters are ready, the script sets data-wordmark3d="on" on the hero and CSS hides the flat letters. If anything fails before that, nothing is hidden and the visitor still sees your text.

  1. Load three.js, the font loader and a room environment in the browser after the component mounts.
  2. Build a camera that maps three.js units to CSS pixels for the hero's current size.
  3. For each CSS letter, generate its outline from the typeface JSON, extrude it with a small bevel, and move it onto the letter's measured position.
  4. Paint the caps and the sides with two materials, and aim a key light from high and to the side.
  5. Run a 14.1 second loop: hold and float (6.5 s), launch (1.3 s), orbit (4.4 s), land (1.9 s).
  6. Stop rendering when the tab is hidden, the hero is off screen, the pause control is on, or the visitor prefers reduced motion.

Step 1: Give your agent the brief

Paste this into Claude Code or Codex from the project root. Replace the parts in square brackets. It names the result, the rules that protect the page, and the checks that decide "done".

TEXT
Rebuild the hero wordmark "[YOUR TEXT]" as solid 3D letters in three.js.

- Keep the real <h1> text. Split it into one span per letter. Hide the letters visually only after WebGL is ready; if WebGL fails, the text stays.
- Extrude each letter from the site's own font ([FONT FILE] converted to three.js typeface JSON) with a small bevel. One mesh per letter.
- Place every letter on the exact position of its CSS letter. Measure offsetLeft/offsetTop, not getBoundingClientRect, so running CSS transforms do not skew it.
- Loop: letters float in a gentle wave -> launch one by one into a tilted orbit, two full turns each -> land back in order with a small overshoot.
- Ink letters in light mode, white letters in dark mode. Re-read colours when the theme changes.
- Mouse tilts the word slightly, on devices with a fine pointer only.
- Respect prefers-reduced-motion (keep the CSS text), the site's pause control, hidden tabs and off-screen.
- Dispose every geometry, material, texture and the renderer on unmount.
- Before calling it done: build, lint, and capture light and dark at 390 and 1440 wide, at the float, orbit and landing moments. Show me the screenshots.

Check

  • The plan repeats the rules before any edit. If it proposes voxels, sprites or a video, stop it.
  • The plan ends with the screenshot step. Add it if it is missing.

Step 2: Keep the real text and a safe fallback

The heading stays real HTML. Screen readers read the aria-label; the letter spans and the canvas host are hidden from them so the word is read once.

TS
<h1 className="hero-name" aria-label="dainer.ai">
  <span aria-hidden="true" className="name-letters">
    {"dainer.ai".split("").map((letter, i) => (
      <span className="letter" key={i}>{letter}</span>
    ))}
  </span>
</h1>
<div ref={host} className="hero-wordmark-3d" aria-hidden="true" />

The CSS puts the canvas above the heading and hides the flat letters only once the script says the 3D word is on:

TEXT
.hero-wordmark-3d { position: absolute; inset: 0; z-index: 4; pointer-events: none; }
.hero-wordmark-3d canvas { display: block; width: 100% !important; height: 100% !important; }
.personal-hero[data-wordmark3d="on"] .name-letters { visibility: hidden; }

Inside the effect, the component returns early when matchMedia("(prefers-reduced-motion: reduce)") matches, loads three.js with dynamic import() calls so it never runs on the server, and wraps new THREE.WebGLRenderer(...) in try/catch. Any early return leaves the attribute unset, so the CSS letters stay.

Check

  • Turn on reduced motion in your OS settings and reload. You see the normal CSS heading.
  • In the console, document.querySelector(".personal-hero").dataset.wordmark3d is "on" only when the 3D word shows.
  • The heading text is in the page source, not only drawn on a canvas.

Step 3: Match the camera to CSS pixels

A narrow field of view looks almost flat, which suits letters that must line up with type. Place the camera at the distance where the visible height at z = 0 equals the hero's height in pixels. Then three.js x equals CSS x, and y is the same number with the sign flipped, because screen y grows downwards.

TS
const FOV = 16;
const camera = new THREE.PerspectiveCamera(FOV, 1, 1, 20000);
W = hero.clientWidth; H = hero.clientHeight;
renderer.setSize(W, H, false);
camera.aspect = W / H;
const d = (H / 2) / Math.tan((FOV * Math.PI) / 360);
camera.position.set(W / 2, -H / 2, d);
camera.near = d * 0.2; camera.far = d * 3;
camera.lookAt(W / 2, -H / 2, 0);
camera.updateProjectionMatrix();

Cap the pixel ratio with renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) so dense phone screens do not render more pixels than they need. The site also sets outputColorSpace to SRGBColorSpace and uses ACESFilmicToneMapping.

Check

  • A test box 100 units wide at (100, -100, 0) should cover the CSS square from 100 px to 200 px of the hero.
  • After a window resize, the box stays on the same CSS position.

Step 4: Extrude each letter and place it on its CSS letter

Wait for the web font before measuring, or every letter lands where the fallback font put it.

TS
const [json] = await Promise.all([
  fetch("/assets/archivo-black-typeface.json").then((r) => r.json()),
  document.fonts.load("900 100px Archivo").catch(() => undefined),
]);
const font = new FontLoader().parse(json);

Then build one mesh per CSS letter. The baseline comes from the font's ascent and descent, measured on a 2D canvas with the same computed font and centred in the letter's line box. The horizontal position comes from offsetLeft, which is the layout position. The CSS letters on this site run a wave animation, and a measurement that includes transforms would catch each letter mid-wave.

TS
const st = getComputedStyle(name);
fs = parseFloat(st.fontSize);
const g2 = document.createElement("canvas").getContext("2d");
g2.font = `${st.fontWeight} ${st.fontSize} ${st.fontFamily}`;
const m = g2.measureText("d");
const asc = m.fontBoundingBoxAscent, desc = m.fontBoundingBoxDescent;

name.querySelectorAll(".letter").forEach((letter, i) => {
  const shapes = font.generateShapes(letter.textContent || "", fs);
  if (!shapes.length) return;
  const geo = new THREE.ExtrudeGeometry(shapes, {
    depth: fs * 0.18, curveSegments: 10,
    bevelEnabled: true, bevelThickness: fs * 0.022, bevelSize: fs * 0.009, bevelSegments: 4,
  });
  geo.computeBoundingBox();
  const bb = geo.boundingBox;
  const gx = (bb.min.x + bb.max.x) / 2, gy = (bb.min.y + bb.max.y) / 2;
  geo.translate(-gx, -gy, -(bb.min.z + bb.max.z) / 2);
  const left = name.offsetLeft + letter.offsetLeft;
  const base = name.offsetTop + letter.offsetTop + (letter.offsetHeight - (asc + desc)) / 2 + asc;
  const mesh = new THREE.Mesh(geo, [face, side]);
  group.add(mesh);
  glyphs.push({ mesh, hx: left + gx, hy: -base + gy, i });
});

Sizes are fractions of the font size (fs), so proportions hold on every screen. Centring each geometry on its own bounding box lets the letter spin in place during the orbit.

Check

  • With the canvas at 50% opacity and the hide rule off, 3D letters sit on the flat letters at 390 and 1440.
  • glyphs.length equals the number of visible letters.
  • A missing letter usually means its character is not in the typeface JSON.

Step 5: Materials and light that keep the word readable

ExtrudeGeometry creates two material groups: the front and back caps, and the side walls including the bevel. Passing [face, side] paints them separately. The caps carry the colour of the word and the sides carry the highlights.

TS
const pmrem = new THREE.PMREMGenerator(renderer);
const envTex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
// envMap is set per material: envMapIntensity is ignored for scene.environment.
const face = new THREE.MeshPhysicalMaterial({ envMap: envTex, roughness: 0.32, metalness: 0.02, clearcoat: 1, clearcoatRoughness: 0.14 });
const side = new THREE.MeshPhysicalMaterial({ envMap: envTex, roughness: 0.22, metalness: 0.35, clearcoat: 1, clearcoatRoughness: 0.08 });
const paint = () => {
  const dark = document.documentElement.dataset.theme === "dark";
  face.color.set(dark ? "#f4f2ec" : "#161715");
  side.color.set(dark ? "#d9d6cd" : "#3a3d39");
  face.envMapIntensity = dark ? 0.9 : 0.025; // light mode: near-matte ink faces
  face.roughness = dark ? 0.32 : 0.55;
  face.clearcoat = dark ? 1 : 0.2;
  side.envMapIntensity = dark ? 1.1 : 0.9;
  key.intensity = dark ? 1.2 : 0.9;
  sweepGain = dark ? 2.2 : 0; // light mode: solid ink, no glint wash
};

The key light is a DirectionalLight placed high and to the left of the word and aimed at its centre: key.position.set(cx - W * 0.6, cy + H * 1.6, fs * 2.5) with its target at the word centre. A moving PointLight with a decay of 2 skims just above the letters during the hold, so a glint travels along the bevels in dark mode.

Check

  • Light mode: the word reads as near-black ink. Sample a flat cap in a screenshot and compare it with your ink colour.
  • Dark mode: caps are off-white and a highlight moves across the bevels.
  • Switch theme while paused. The still frame repaints in the new colours.

Step 6: Write the motion loop

The loop is a pure function of time. Given a time, it sets every letter's position, rotation and scale. Nothing accumulates, so nothing drifts, and pausing is just not calling it.

TS
const HOLD = 6.5, LAUNCH = 1.3, ORBIT = 4.4, LAND = 1.9;
const CYCLE = HOLD + LAUNCH + ORBIT + LAND;
const clamp = (t) => Math.min(1, Math.max(0, t));
const smooth = (t) => t * t * (3 - 2 * t);
const outBack = (t) => { const c = 1.6; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); };
const orbitAt = (i, n, time) => {
  const rx = Math.min(wordW * 0.58, W * 0.36), ry = Math.max(wordH * 0.9, fs * 0.7), rz = rx * 0.9;
  const a = (i / n) * Math.PI * 2 + time * 1.25;
  return { x: Math.cos(a) * rx, y: Math.sin(a) * ry * 0.55 - Math.cos(a) * ry * 0.35, z: Math.sin(a) * rz };
};

const t = ((time % CYCLE) + CYCLE) % CYCLE;
for (const g of glyphs) {
  const lx = g.hx - cx, ly = g.hy - cy;
  const wave = Math.sin(time * 1.7 - g.i * 0.6);
  const hy = ly + wave * fs * 0.035;
  const o = orbitAt(g.i, glyphs.length, time);
  let x = lx, y = hy, z = 0, rx = wave * 0.14, ry = 0, rz = 0, s = 1;
  if (t >= HOLD) {
    const tl = t - HOLD;
    const launch = smooth(clamp((tl - g.i * 0.09) / (LAUNCH - 0.3)));
    const land = outBack(clamp((tl - LAUNCH - ORBIT - g.i * 0.1) / (LAND - 0.9)));
    const f = tl < LAUNCH + ORBIT ? launch : 1 - land;
    x = lx + (o.x - lx) * f; y = hy + (o.y - hy) * f; z = o.z * f;
    ry = smooth(clamp(tl / (LAUNCH + ORBIT + LAND - 0.2))) * Math.PI * 4; // two full turns
    rx = rx * (1 - f) + Math.sin(time * 2 + g.i) * 0.35 * f;
    rz = Math.sin(time * 1.3 + g.i) * 0.2 * f;
    s = 1 - 0.18 * f;
  }
  g.mesh.position.set(x, y, z);
  g.mesh.rotation.set(rx, ry, rz);
  g.mesh.scale.setScalar(s);
}

The small per-letter delays make the letters leave and return in reading order. Two full turns (Math.PI * 4) bring each letter back facing the front, so the landing never shows a mirrored letter. The orbit radius is capped by the word width and the hero width, so it still fits on a phone.

Check

  • Capture the hero at about 2.5 s, 7.1 s, 9.5 s and 12.8 s: float, launch, orbit, landing.
  • At the end of each cycle every letter is back on its CSS position, facing front.
  • Mouse tilt runs only when matchMedia("(pointer: fine)") matches.

Step 7: Respect pause, hidden tabs, resize and theme

A decorative loop must stop when nobody can see it. One wants() test and one sync() function start or stop the frame loop, and a still frame of the assembled word is drawn when it stops.

TS
const wants = () => visible && !document.hidden && root?.dataset.motion !== "off";
const sync = () => {
  if (wants()) { if (!raf) { startedAt = performance.now(); raf = requestAnimationFrame(loop); } }
  else if (raf) { cancelAnimationFrame(raf); raf = 0; t0 += elapsed(); still(); }
};
new IntersectionObserver(([en]) => { visible = en.isIntersecting; sync(); }, { threshold: 0.02 }).observe(hero);
document.addEventListener("visibilitychange", sync);

A MutationObserver on the pause control's data-motion attribute calls sync, another on data-theme calls paint(), and a ResizeObserver rebuilds the letters 120 ms after the last resize. On unmount, disconnect everything and dispose every geometry, material, texture, the PMREM generator and the renderer.

Check

  • Click the pause control. The letters freeze on the assembled word.
  • Leave the tab for ten seconds and return. The loop continues from where it stopped.
  • Resize from desktop to phone width. The letters rebuild and still line up.

Step 8: Capture and review before you call it done

This is the core of the capture script used for the hero. It opens the page in light and dark mode at desktop and phone sizes, logs page errors, confirms the 3D word mounted and saves the hero.

JS
import { chromium } from "playwright";
const b = await chromium.launch({ channel: "chrome", args: ["--use-angle=metal", "--enable-gpu"] });
for (const theme of ["light", "dark"]) for (const [w, h] of [[1440, 900], [390, 844]]) {
  const p = await b.newPage({ viewport: { width: w, height: h }, colorScheme: theme });
  p.on("pageerror", (e) => console.log("pageerror:", e.message));
  await p.goto("http://127.0.0.1:3000/");
  await p.waitForTimeout(2500);
  console.log(theme, w, await p.evaluate(() => document.querySelector(".personal-hero")?.dataset.wordmark3d));
  await p.locator(".personal-hero").screenshot({ path: `hero-${theme}-${w}.png` });
  await p.close();
}
await b.close();

channel: "chrome" uses your installed Chrome, and the two GPU flags are what we used on a Mac to get WebGL frames. A timed wait is fine here because you are sampling an animation on purpose.

Check

  • Every run prints 3d = on and no pageerror lines.
  • Open each PNG yourself. Look for grey letters, letters clipped by the hero edge during the orbit, and mirrored letters on landing.
  • Note what you could not check, such as smoothness on a real phone.

Gotchas we hit

  • Voxel letters looked like a pixel font. The first attempt built letters from small cubes and read as a retro game. Extruding the real font outlines fixed it.
  • A key light near the camera turned black letters grey. Light from the viewer's direction puts a sheen on every flat face that faces the viewer. Moving the key light high and to the side kept the caps at their true colour.
  • envMapIntensity did nothing. In three.js 0.185 the renderer only sends material.envMapIntensity to the shader when the material has its own envMap. With only scene.environment, set envMap per material or use scene.environmentIntensity.
  • Letters landed mid-wave. getBoundingClientRect() returns the rendered box including CSS transforms. offsetLeft and offsetTop return the layout position.
  • Letters shifted after the web font arrived. Waiting on document.fonts.load(...) before the first build fixed it.
  • Resize rebuilt too often. A 120 ms debounce and disposing old geometry before rebuilding stopped the stutter.

Take it further

  • Swap the orbit for your own path, as long as every path starts and ends on the CSS letter.
  • Read three.js: why your black 3D text looks grey for the full lighting diagnosis.
  • Read Use your own font in three.js: convert it to typeface JSON to make the font file.
  • Read Brief: a Rubik's cube that scrambles and solves itself in three.js for the same lifecycle pattern on 386 instanced pieces.
  • Read AI-assisted visual QA to turn Step 8 into a reusable check.

Quick checklist

  • The real <h1> text is in the HTML with an aria-label.
  • CSS letters are hidden only after data-wordmark3d="on" is set.
  • Reduced motion and missing WebGL both leave the flat heading visible.
  • Letters are placed with offsetLeft/offsetTop after the web font loads, and line up at 390 and 1440.
  • Caps and sides use separate materials, each with its own envMap.
  • The key light is high and to the side, not at the camera.
  • Pause, hidden tab and off-screen stop the loop on a still, assembled word.
  • Everything is disposed on unmount, and captures show no page errors.