DEV Community

Cover image for StringTune-3D: Forcing 3D to Obey CSS
Vladyslav
Vladyslav

Posted on

StringTune-3D: Forcing 3D to Obey CSS

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:

  • hover works as usual
  • layout lives 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Basic HTML

<div
  class="shape"
  string="3d"
  string-3d="box"
  string-3d-material="standard[#0000ff]"
>
  BOX
</div>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And the markup:

<div
  class="shape"
  string="3d"
  string-3d="model"
  string-3d-model="/models/damaged_helmet.glb"
  string-3d-model-fit="contain"
></div>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The module scans the DOM and finds elements with string="3d".
  2. For each element, it creates a corresponding 3D object (mesh, light, group, or model).
  3. A canvas overlay renders the scene above the viewport.
  4. 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;
Enter fullscreen mode Exit fullscreen mode

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)