How to make a 3D website with Claude

Claude writes a working Three.js scene first time. The camera, material, light and scroll values it cannot guess are all listed here, ready to paste.

A deep three dimensional scene built entirely of translucent glass geometry, cubes, spheres and planes receding far into black space with real perspective
The camera is at 35 degrees because a number put it there.

Claude writes a working Three.js scene on the first pass, reliably. What it cannot do is guess your focal length, so it takes the library default: a 50 degree field of view, MeshStandardMaterial at roughness: 1, one white DirectionalLight at intensity 1, OrbitControls, and a mesh rotating at a constant speed forever.

That page is technically three dimensional and visually nothing. The object reads as gray plastic because nothing told the material what it is made of, and the highlights blow out to flat white because nothing told the renderer how to map them.

Write the scene as values and it lands in one pass. About a dozen numbers do the whole job, and every one of them is below.

What comes back when a brief says 3D#

Ask for a three dimensional hero with no numbers in the request and the output is consistent enough to predict line by line.

  • A TorusKnotGeometry or an IcosahedronGeometry, because those are the two primitives that look like something without a model file.
  • OrbitControls, which hands the visitor a turntable and hands you a hero they can spin upside down.
  • One DirectionalLight, usually near the camera, which is the one position that removes every shadow that would describe the form.
  • MeshStandardMaterial with a color and nothing else, so the surface has no roughness story, no clearcoat and no environment to reflect.
  • A requestAnimationFrame loop adding a fixed amount to rotation.y on every frame, which is motion tied to a clock rather than to a reader.
  • No tone mapping, no output color space set, no device pixel ratio clamp.

Each of those is a reasonable library default and a poor design decision, and they share one cause: the brief left the decision open. A model fills an open decision with the most common answer in its training data, which is exactly what a default is.

A longer request does not cure it. A paragraph saying cinematic, premium and modern leaves every one of the six items above unspecified, so the six come back unchanged. Adjectives do not close decisions, and an open decision is where a default lives.

The cure is naming values, and there are fewer of them than people expect. Twelve settings cover a hero scene end to end, eleven of the twelve are a single number or a single constant, and none of them require you to know how a renderer works. You only have to state the answer instead of leaving the question open, which is the same discipline that makes a specified brief beat a longer one.

Kickflip states its camera as numbers in a live scene, and Caravan gets its depth from light and shadow with no 3D engine at all:

The scene, written as values#

Twelve settings separate a default scene from a directed one. Each maps to a single argument in an API Claude already knows, so a brief naming them gets honored rather than approximated.

SettingValue to nameWhat the default does instead
CameraPerspectiveCamera(35, aspect, 0.1, 60)50 degrees, which fattens the object and flattens the light
Camera position(0, 0.45, 5.2) looking at (0, 0.15, 0)dead center at eye height, the one angle with no drama in it
Pixel ratioMath.min(window.devicePixelRatio, 2)uncapped, so a phone renders nine times the pixels it needs
Tone mappingACESFilmicToneMapping, exposure 1.12none, so every specular highlight clips to #FFFFFF
Color spaceoutputColorSpace = SRGBColorSpaceright since r152, if the textures are tagged too
EnvironmentRoomEnvironment through PMREMGenerator, envMapIntensity: 1.25a black void, which no real surface has ever reflected
Key lightDirectionalLight(0xffffff, 2.6) at (3.2, 4.8, 2.1)intensity 1 beside the camera
FillAmbientLight(0x8FA6C4, 0.35), cool against the warm keynothing, so the shadow side goes to pure black
ShadowsPCFShadowMap, mapSize 2048, normalBias 0.02off, or on with acne across every curved face
FogFogExp2(0x0B0B0F, 0.085)none, so the object floats with no depth cue
Controlsnone, or enableZoom: false and maxPolarAngle: 1.45full orbit, including upside down
Backgroundone flat value, #0B0B0Fa gradient, standing in for a decision nobody made

Two rows outweigh the rest. A field of view of 35 compresses depth the way a portrait lens does, which is what makes a render read as photographed rather than rendered. With no environment map, reflection has nothing to describe, so every surface reads as paint.

One row changed with the library. three.js deprecated PCFSoftShadowMap in r182 and removed it in r186; asking for it now logs a warning and falls back to PCFShadowMap, so name that one.

The material carries the look#

A surface is four numbers, and roughness is the one that decides everything. Below 0.1 you get a mirror, around 0.3 you get lacquer, near 0.45 you get fired ceramic, past 0.7 you get chalk. Naming a band rather than a mood is the entire difference between a directed material and a default one.

js
const ceramic = new THREE.MeshPhysicalMaterial({
  color: new THREE.Color("#E8E2D6"),
  roughness: 0.42, metalness: 0,
  clearcoat: 0.35, clearcoatRoughness: 0.28,
  sheen: 0.45, sheenColor: new THREE.Color("#FFD2A8"),
  envMapIntensity: 1.25,
});

const glass = new THREE.MeshPhysicalMaterial({
  transmission: 1, thickness: 1.4, ior: 1.48,
  roughness: 0.08, metalness: 0,
  attenuationColor: new THREE.Color("#7FB2C9"), attenuationDistance: 0.9,
});
A single glass sphere floating above a reflective black plane with a precise glass camera frustum aimed at it from the side, construction lines visible
Roughness 0.42 and one warm key. No gradient anywhere in the frame.

The glass recipe is where most generated scenes go wrong, because transmission without thickness produces a soap bubble. Thickness is what gives refraction something to bend through, and attenuationColor with attenuationDistance is what makes a thick edge read as tinted while a thin one stays clear. That pair is the difference between glass and a transparent gray.

Name one material and one accent, never three of each. A scene with a single directed surface and a single light temperature contrast looks composed; a scene with chrome, glass and ceramic in it looks like a demo of the material system, which is the same failure as a page that shows off every effect at once.

Bind the camera to the scroll, not to a clock#

A mesh that spins forever is the clearest tell that nobody directed the scene. Motion that responds to the reader is a different category of thing, and with GSAP it is one timeline.

js
gsap.registerPlugin(ScrollTrigger);
gsap.timeline({ scrollTrigger: {
  trigger: "#stage", start: "top top", end: "+=220%",
  pin: true, scrub: 1, anticipatePin: 1, invalidateOnRefresh: true,
}})
  .to(camera.position, { z: 3.1, y: 0.22, duration: 1, ease: "none" })
  .to(model.rotation, { y: 0.62, duration: 1, ease: "none" }, "<")
  .to(camera.position, { x: 1.35, duration: 1, ease: "none" });

Four of those values are decisions, not boilerplate. end: "+=220%" sets the scroll budget: the stage stays pinned for two and a fifth viewport heights, which is enough for two camera moves and not enough to feel like a hostage situation. scrub: 1 gives the timeline a one second catch-up, so the camera glides instead of snapping to every wheel tick. ease: "none" is mandatory under a scrub, because the reader's scroll is already the easing curve and a second one fights it. And anticipatePin: 1 removes the single frame jump that makes a pinned section feel broken on a trackpad.

For the render loop itself, damp toward a target rather than assigning to it: THREE.MathUtils.damp(current, target, 4, delta) at a lambda of 4 closes about 70% of the gap in 300ms and 95% by 750ms, and it is frame rate independent, which matters the moment someone opens the page on a 120Hz phone.

Wrap the whole thing in gsap.matchMedia() keyed to (prefers-reduced-motion: no-preference) and give the reduced branch the final camera position with no animation. That is two extra lines and it is the difference between a scene that respects an accessibility setting and one that ignores it.

Two pages whose motion answers the visitor rather than a clock:

The verdict, and the block to paste#

Hand Claude the values and the default scene never appears. Hand it adjectives and you get the torus knot, every time, because a model fills every gap in the brief with the most average answer available.

Copy this into the prompt, change the numbers to your own, and the output is directed rather than generic:

  1. Camera 35 degrees, at (0, 0.45, 5.2), target (0, 0.15, 0), no orbit controls.
  2. ACESFilmicToneMapping at exposure 1.12, DPR clamped to 2.
  3. RoomEnvironment through PMREMGenerator, envMapIntensity: 1.25.
  4. Key DirectionalLight 2.6 at (3.2, 4.8, 2.1), cool AmbientLight 0.35. Soft shadows, normalBias 0.02.
  5. One material: roughness: 0.42, metalness: 0, clearcoat: 0.35.
  6. Ground #0B0B0F, FogExp2 at 0.085, no gradient anywhere.
  7. Camera on ScrollTrigger, pinned +=220%, scrub: 1, ease: "none", reduced motion branch holds the end frame.

The block works the same in a Claude artifact you preview beside the conversation, as in building a site with Claude Artifacts, and in Claude Code working in your own repository, as in the Claude Code walkthrough. Add one line either way, the release: three.js is at r187 at the time of writing, and naming three@0.187 keeps a model trained on older code from reaching for APIs that have gone, PCFSoftShadowMap among them. The three.js examples piece covers the rest of what changed.

That is the shape of every brief worth pasting, and writing one per project is the part that takes longer than the build. Thanor is a library of website designs, each shown as a live preview and each with a long prompt written as values: palette, type scale, spacing, curves and durations, section order and every interaction. Free accounts open the designs marked free, and the plans open the rest.

Open Kickflip, walk to a rack and turn a deck to face you, then compare the camera with the table above; its prompt writes every pose as a position, a yaw, a pitch and a field of view. Vesperion is free with an account if you want to read one prompt end to end first. For the prompt rather than the scene anatomy, the companion piece is a 3D website AI prompt broken into its seven fields, the techniques beyond a live scene are in the best 3D websites, and the reason defaults converge at all is set out in the pillar.

Questions this raises

Can Claude actually build a 3D website?

Yes, and in one pass. It writes the Three.js or React Three Fiber setup, the render loop, the resize handler and the scroll binding without stalling. What it cannot do is invent your focal length, roughness value or scroll distance, so it uses the library defaults unless the brief names them.

Which 3D library should the prompt name?

Three.js directly for a single hero object, because the whole scene is about eighty lines and nothing else needs to know about it. React Three Fiber with drei when the page is already React and the scene has to react to component state.

How heavy can a hero scene be?

Keep the visible triangle count under 150,000, textures at 2048px on the long edge, and the glb compressed with Draco or meshopt. Clamp the device pixel ratio to 2, which is the largest single saving on a phone.

Does a 3D hero slow the page down?

Not when the render loop is paused off screen by an IntersectionObserver, the canvas has explicit width and height so nothing shifts, and the model loads after first paint. The cost is bandwidth, and a compressed glb of one object is smaller than most hero videos.

Part of

Build it with AI

Three dimensional scenes, scroll driven motion and animated pages, built by handing a model a specification instead of an adjective. What to write, and what comes back.