There is something oddly appealing about the idea of controlling a 3D world through plain CSS.
It’s like standing in the middle of a disassembled engine, covered in grease, catching yourself thinking:
What if, instead of a wrench, I just use…
:hover?
Logic says this will end badly. The browser seems to agree.
But I got hooked.
I’m building String3D — a module for StringTune that allows you to:
- Drop 3D objects right into an HTML layout
- Bind them to DOM elements
- Control transformations via CSS custom properties
No mousemove orchestra, no “half-a-project” worth of glue code, no constant
“recalculate my position because the layout shifted again.”
Here is what it looks like in action:
What this is (and what it isn’t)
Let’s kill the magic expectations right now: String3D is not “CSS 3D”.
There are no tricks here where the browser suddenly learned to render meshes natively.
Under the hood, it’s a standard 3D renderer in a canvas living above the page.
The difference lies elsewhere.
String3D synchronizes 3D objects with DOM elements every frame, but it takes its behavioral instructions from CSS custom properties.
The DOM remains the DOM:
-
hoverworks as usual -
layoutlives its normal, slightly chaotic life
The 3D layer doesn’t control the page or try to replace it.
It just looks at the DOM and says:
“Show me where you are.
What size you are.
How you are rotating.
I will repeat it.”
The DOM is the source of truth.
The 3D is a shadow that obeys.
Quick Start
Installation
npm install string-tune string-tune-3d three
JavaScript Initialization
import StringTune from 'string-tune';
import { ThreeJSProvider, String3D } from 'string-tune-3d';
import * as THREE from 'three';
const stringTune = StringTune.getInstance();
String3D.setProvider(new ThreeJSProvider(THREE));
stringTune.use(String3D);
stringTune.start(60);
Basic HTML
<div
class="shape"
string="3d"
string-3d="box"
string-3d-material="standard[#0000ff]"
>
BOX
</div>
After this, the blue cube is tied to the element:
- It takes its position from the layout
- Its size too
- It picks up resizes without a “please update” plea
Yes, a cube in the DOM isn’t the peak of human intellect.
But this is the moment you realize:
“Okay, this actually shapes up into a decent abstraction.”
CSS as the 3D Control Panel
I started with a minimal set of variables:
.shape {
--translate-x: 0;
--translate-y: 0;
--translate-z: 0;
--rotate-x: 0;
--rotate-y: 0;
--rotate-z: 0;
--scale: 1;
}
And here is the key moment.
You change these variables on the HTML element — and the 3D object does the exact same thing.
At this point, CSS stops being just styles and becomes an API.
Now CSS animations, pseudo-classes, and media queries all become tools for controlling 3D.
Models: Yes, GLTF works too
Once you stop playing with cubes, the fun starts.
Connecting the loader:
String3D.setProvider(
new ThreeJSProvider(THREE, { gltf: GLTFLoader })
);
const stringTune = StringTune.getInstance();
stringTune.use(String3D, {
modelLoaderFactory: () => new GLTFLoader(),
});
stringTune.start(60);
And the markup:
<div
class="shape"
string="3d"
string-3d="model"
string-3d-model="/models/damaged_helmet.glb"
string-3d-model-fit="contain"
></div>
fit="contain" (or cover) is essentially object-fit, but for 3D:
the model adapts to the element size, not the other way around.
Examples: CSS doing the heavy lifting
.shape {
transition: --rotate-y 300ms, --scale 300ms;
}
.shape:hover {
--rotate-y: 180;
--scale: 1.2;
}
Or keyframes:
@keyframes spin {
0% {
--translate-x: 0;
--rotate-y: 0;
--rotate-x: 0;
--scale: 1;
}
100% {
--translate-x: 100;
--rotate-y: 120;
--rotate-x: 120;
--scale: 2;
}
}
.shape.is-spinning {
animation: spin 2s linear infinite;
}
The real meaning: when modules play together
Here is where String3D stops being a demo and becomes a tool.
I took StringProgress (scroll state) and StringImpulse (interaction / push physics) and tied their CSS variables to the 3D transformations.
Markup:
<div class="shape-wrapper" string="progress" string-enter-vp="top">
<div
class="shape"
string="3d|impulse"
string-3d="model"
string-3d-model="/models/damaged_helmet.glb">
</div>
<div string="3d" string-3d="ambientLight" string-3d-color="#ffffff" string-3d-intensity="3.5"></div>
<div string="3d" string-3d="directionalLight" string-3d-color="#ffffff" string-3d-intensity="3"></div>
</div>
CSS:
.shape {
/* Scroll drives rotation and X movement */
--translate-x: calc(var(--progress) * 360 + var(--push-x, 0));
--rotate-y: calc(var(--progress) * 310);
/* Mouse adds impulse */
--translate-y: var(--push-y, 0);
--rotate-z: var(--push-rotation, 0);
/* Scale is also tied to scroll */
--scale: calc(1 + var(--progress));
}
JS doesn’t drive the animation directly.
It only provides state (--progress, --push-x, --push-rotation) — produced by StringTune’s core modules like scroll and interaction tracking.
The entire logic of how it looks lives in CSS.
And that is the whole idea.
How it works (no mythology)
If we strip it down to the verifiable basics:
- The module scans the DOM and finds elements with
string="3d". - For each element, it creates a corresponding 3D object (mesh, light, group, or model).
- A canvas overlay renders the scene above the viewport.
- Every frame:
- Reads
getBoundingClientRect()and computed styles - Extracts CSS vars (
--translate-*,--rotate-*,--scale) - Converts DOM coords to world coords via the camera
- Sets position, rotation, and scale
- Renders the scene
DOM is the source of truth.
3D is a shadow that obeys CSS.
By default, an orthographic camera is used, so the binding feels like
“1px ≈ 1 unit”, without the circus of perspective distortion.
Why did I even get into this?
Because I got tired of the same old scenario.
You build a layout in HTML.
Then you add Three.js.
Then it begins: “now sync this,” “now resize,” “now make it sticky,” “now handle another breakpoint.”
And in the end, you have either two worlds constantly fighting each other,
or a massive pile of glue code that is scary to touch.
I want a different mode:
HTML builds the world.
CSS describes the behavior.
3D connects as a layer.
Right now, this code is a mix of heuristics, compromises, and pure enthusiasm.
There are places in the source I prefer not to look at too often, and I know for a fact some parts will need a rewrite.
Logically, it shouldn’t work like this.
We’re tricking the system, forcing the DOM to think it controls something it doesn’t even know exists.
It’s a hack.
But when I write:
transition: --rotate-y 1s;
save the file, and watch a heavy 3D model rotate smoothly in the browser
without a single line of JS — I get a strange feeling.
It feels like finding that one missing bit the web has been lacking all these years.
Maybe it’s an illusion.
Or maybe this is how it was supposed to be from the start.
If you’ve built something similar — or tried and failed — I’d love to hear how you approached it.
Especially if you ended up with a pile of glue code.
Links
Assets & Credits
Damaged Helmet (source .glb)
https://sketchfab.com/3d-models/damaged-helmet-source-glb-fc8449ca5f3f490d83ba6a7ae087ff55
Licensed under CC-BY 4.0 (via Sketchfab)
If it breaks — great.
Send me what broke, where, and in which browser.
That’s exactly what I’m hunting for.
Top comments (0)