# polycss: a 3D engine that renders polygon meshes as DOM elements

> PolyCSS is a CSS 3D engine for the DOM that turns OBJ, STL, glTF, GLB, and MagicaVoxel files into real HTML elements transformed with CSS matrix3d, with lighting, shadows, textures, and four control schemes. It ships as a vanilla package, React and Vue bindings, and a separate morphing package for models that are prepared ahead of time.

**layoutit/polycss** — A CSS 3D engine for the DOM. Renders polygon meshes in HTML by leveraging matrix3d transforms. 

- Repository: https://github.com/layoutit/polycss
- Website: https://polycss.com
- Stars: 771 · Forks: 21
- Language: TypeScript
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/layoutit-polycss

## Real elements, not a canvas

PolyCSS is a CSS polygon mesh library, described as a 3D engine for the DOM. The repository description puts the mechanism in one sentence: it renders polygon meshes in HTML by leveraging matrix3d transforms.

That is the entire premise, and it has consequences in both directions. On the positive side, a face in the scene is a real element, so it has a class, it can have a click handler, it can be styled with CSS, and it is inspectable in the developer tools. On the negative side, the renderer is doing per-face work in the browser's layout and compositing engine rather than handing the geometry to a GPU pipeline, which is the trade you accept by choosing a DOM based engine.

The scope of the library is wider than a demo. It renders OBJ and MTL, STL, glTF and GLB, and VOX files, and it supports colors, textures, lighting, shadows, shapes, and animations. The component model is the same across React, Vue, and plain JavaScript, and the installation is three packages, one for each of the vanilla, React, and Vue flavours.

```bash
# Vanilla
npm install @layoutit/polycss

# React
npm install @layoutit/polycss-react

# Vue
npm install @layoutit/polycss-vue
```

The project is MIT licensed, the site is polycss.com, the last push was on 2026-08-25, and the repository has no releases, which fits a package set published from a monorepo rather than a tagged project.

The CDN route is also supported, and the minimal custom element scene it shows is small enough to be worth reading in full, because it shows the shape of the whole API without any framework in the way.

```js
<script type="module" src="https://esm.sh/@layoutit/polycss/elements"></script>

<poly-camera rot-x="65" rot-y="45">
  <poly-scene>
    <poly-orbit-controls drag wheel></poly-orbit-controls>
    <poly-box size="100" color="#ffd166"></poly-box>
  </poly-scene>
</poly-camera>
```

## Camera, scene, mesh, and the control schemes

The component model is three pieces, and the division of responsibility is clean.

PolyCamera owns the viewpoint. The rotX and rotY props control the orbit angle in degrees, zoom scales the projected scene, target pans the camera target in world coordinates, and distance adds a dolly pull-back. The camera is orthographic by default, and the documentation says to use the perspective camera when you want perspective depth.

PolyScene owns lighting and options. It renders a static array of polygons directly, it takes the directional light, the point lights, and the ambient light, it chooses between baked and dynamic texture lighting, it sets the texture quality, which is described as the atlas raster budget, it can disable selected render strategies for diagnostics, and it can rotate around the rendered mesh bounds instead of the world origin.

PolyMesh loads or receives the polygon data. The src prop loads an OBJ, a glTF, a GLB, or a VOX file, the mtl prop loads the companion OBJ material file, the polygons prop accepts pre-parsed geometry, and position, scale, and rotation transform the mesh wrapper.

Then there are the controls, and there are four of them, which is more than most libraries of this kind offer. The orbit controls add drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. The map controls use pan-first map-style input. The first-person controls provide keyboard and pointer-look navigation. And the transform controls add translate and rotate gizmos for selected mesh handles.

The React example shows the whole stack in one component, with the camera wrapping the scene, the scene wrapping the controls and the mesh, and the mesh pointing at an OBJ and its MTL file.

```ts
import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react";

export default function App() {
  return (
    <PolyCamera rotX={65} rotY={45}>
      <PolyScene textureLighting="dynamic">
        <PolyOrbitControls drag wheel />
        <PolyMesh src="/gallery/obj/cottage.obj" mtl="/gallery/obj/cottage.mtl" />
      </PolyScene>
    </PolyCamera>
  );
}
```

The transform controls are the one to notice, because they imply an editing workflow rather than a viewing one. A mesh you can select and move with a gizmo is a mesh you are placing in a scene, not just displaying.

## Mesh formats, and how differently they behave on import

The loadMesh function parses the supported model formats, and the API reference is short.

```ts
import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss";

const host = document.getElementById("polycss")!;
const camera = createPolyCamera({ rotX: 65, rotY: 45 });
const scene = createPolyScene(host, { camera });

const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", {
  mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl",
});

scene.add(mesh);
```

The supported formats are listed, and the differences between them matter more than the list itself.

OBJ with MTL is supported, including the diffuse texture map and the UV coordinates. glTF and GLB are supported, including embedded images and the TEXCOORD_0 attribute. MagicaVoxel VOX files are supported, with direct voxel fast paths when eligible. And there are generated primitives, so you do not need a model file to get a shape: box, plane, ring, sphere, torus, cylinder, cone, and the Platonic solids.

STL is the odd one out, and the documentation explains why. STL has no standard units, no textures, no UVs, and no hierarchy, so imports skip the lossy simplification and the ray-based interior culling that the other formats get. The mesh resolution prop chooses between a lossy mode, which is the default, and a lossless one, and STL imports take the conservative lossless path in both modes.

The units point deserves attention for anyone using STL. A mesh with no units is a mesh with no scale, so a model that is 1 unit across in the file may be 1 millimetre or 1 kilometre in the scene, and the scale prop on the mesh is where you correct for it.

The interior culling detail is also interesting, because it is a rendering decision with a visible consequence. Culling the interior of a closed mesh means you are not emitting faces you can never see, which roughly halves the element count for a solid object, and skipping it for STL means the conservative path is chosen for a format that often carries faceted, non-manifold geometry.

## Per-face DOM, and the data model underneath

If you do not need a loaded model, you can render polygons directly, and the data model for a polygon is small enough to read at a glance. Each polygon describes one renderable face.

```ts
const polygons = [
  {
    vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]],
    color: "#f97316",
  },
  {
    vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]],
    texture: "/texture.png",
    uvs: [[0, 0], [1, 0], [1, 1], [0, 1]],
  },
];
```

Three vertices with a color, or four vertices with a texture and the matching UV coordinates. That is the whole format, and it is the same shape of data a loader produces, which means a custom parser only has to produce this structure.

And the reason to build polygons yourself is stated directly: render polygons directly when you need per-face DOM events or custom styling.

```ts
<PolyCamera>
  <PolyScene>
    {polygons.map((polygon, index) => (
      <Poly
        key={index}
        {...polygon}
        onClick={() => console.log("clicked polygon", index)}
        className="my-polygon"
      />
    ))}
  </PolyScene>
</PolyCamera>
```

That is the capability that separates this from a canvas renderer. A click handler on one face of a mesh, with a class you can target in your stylesheet, is not something a canvas can give you without you reimplementing hit testing, and for a product mockup or a configurator that is the entire point of rendering into the DOM.

The lighting props work at the same granularity. The directional light, the point lights, and the ambient light are scene level, the point lights are direction-only in baked mode with an optional per-light castShadow, and the castShadow prop on the mesh emits CSS-projected shadows in dynamic lighting mode. The autoCenter prop on the mesh shifts the bounding box centre to the local origin, and the same prop on the scene rotates around the rendered mesh bounds rather than the world origin, which is what stops a model that was modelled away from the origin from appearing off screen.

## Snapshot export, and the case for a self-contained document

One feature deserves a section of its own, because it solves a problem that is awkward for every DOM based 3D renderer.

The vanilla package exports a function that takes the current rendered camera or scene DOM, clones it, injects only the CSS that the snapshot needs, inlines the CSS image assets as base64 data URLs, strips the scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React and Vue scenes as well, and you pass it the rendered camera or scene element.

```ts
import { exportPolySceneSnapshot } from "@layoutit/polycss";

const html = await exportPolySceneSnapshot(scene.host);
```

The failure mode is specified too. If any referenced asset cannot be inlined, the function throws a dedicated error with the code ASSET_INLINE_FAILED, so a caller can distinguish a failed export from a bug in the scene.

What this buys is the ability to hand someone a single file. A canvas based scene exports as an image, which is fine for a screenshot and useless for a document you want to keep interactive or to hand to a colleague who does not have your build pipeline. A DOM based scene can be serialised into HTML that opens in a browser on its own, with the assets embedded and no runtime to fetch, which is the difference between a snapshot and a deliverable.

The stripping of scripts and inline event handlers is worth noting for a different reason. It is a sanitisation step, and it means the exported document does not carry the application's behaviour, which is what you want for a file you are going to email to someone.

## PolyCSS Morph, and the monorepo that produces all of this

The last package is a different idea, and it is aimed at a specific problem.

PolyCSS Morph is for models that are built ahead of time and updated through a stable PolyCSS DOM graph. The preparation runs in Node and produces a package on disk.

```ts
import { preparePolyMorphModel } from "@layoutit/polycss-morph/prepare";

await preparePolyMorphModel({
  configPath: "./source/prepare.json",
  outputRoot: "./public/model/package",
});
```

The browser entry then loads, mounts, samples, and applies the updates.

```ts
import {
  createPolyMorphDeformationRuntime,
  loadPolyMorphPackage,
  mountPolyMorphModel,
} from "@layoutit/polycss-morph";

const loaded = await loadPolyMorphPackage("/model/");
const mounted = mountPolyMorphModel(host, loaded.model, {
  resources: loaded.resources,
});
const deformation = createPolyMorphDeformationRuntime(loaded.model);
const frame = deformation.sample({
  tick: 0,
  morphWeights: { "corner-lift": 0.5 },
});

mounted.apply({ leaves: frame.leafUpdates });
```

The shape of that API is the interesting part. The model is sampled with weights and a tick, and the result is a set of leaf updates that are applied to the mounted DOM. So the deformation is a data pipeline, not a re-render of the whole scene, and the DOM graph stays stable while the shape changes. The guide is linked for an interactive cube-to-sphere example, which is the kind of demo that shows the idea in a few seconds.

The monorepo behind all of this is the last thing to look at, because the scripts tell you how the project is managed. The root package is private, named polycss-monorepo, with pnpm as the package manager at version 10.32.1 and Node 22.12 or newer required. The build and test scripts fan out across the packages with filters, and there are separate publish paths.

Two of those scripts are unusual and worth noting. There is a sync for the package readmes and a sync for the skill documentation, each with a check mode, which means the documentation that ships with each package is generated from one place rather than maintained twice. And there is a check for boundaries and a check for mirrors, which in a monorepo with several published packages is how you keep a package from importing something it should not, and keep the published copies in step.

The repository also carries an agents directory, an AGENTS.md and a CLAUDE.md, a bench directory, an eval directory with a skill runner and a self-test, and examples for the three flavours, HTML, React, and Vue.

## Conclusion

polycss fits a team that needs to show a 3D model inside an ordinary web page, with the model composed of real elements that can be styled, clicked, and inspected, rather than a canvas where nothing in the scene is a DOM node. It does not fit a project that needs a WebGL renderer, since this is a CSS transform based engine with a different performance profile, and it does not suit a team that wants the model to be a single image for social sharing. Verify first that the model format you have is supported, because the import behaviour differs sharply between them, in particular that STL has no units, textures, or hierarchy, that your browsers support the CSS 3D transform features the renderer depends on, and that the model resolution option suits your accuracy requirement, since the default path is lossy.

## FAQ

### What is PolyCSS?

It is a CSS polygon mesh library, a 3D engine for the DOM that renders OBJ and MTL, STL, glTF and GLB, and VOX files as real HTML elements transformed with CSS matrix3d. It supports colors, textures, lighting, shadows, shapes, and animations, and works with React, Vue, or plain JavaScript.

### How do I install PolyCSS?

With npm install @layoutit/polycss for the vanilla package, @layoutit/polycss-react for React, and @layoutit/polycss-vue for Vue. You can also load it directly from a CDN as an ES module and use the custom elements, with a poly-camera containing a poly-scene.

### What mesh formats does PolyCSS support?

OBJ with MTL including diffuse textures and UVs, STL triangle meshes including binary Magics face colors, glTF and GLB including embedded images and TEXCOORD_0, and MagicaVoxel VOX files. It also has generated primitives such as box, plane, ring, sphere, torus, cylinder, cone, and the Platonic solids.

### Why is STL handled differently from other formats in PolyCSS?

STL has no standard units, no textures, no UVs, and no hierarchy, so imports skip the lossy simplification and the ray-based interior culling used for the other formats and take the conservative lossless path in both resolution modes.

### What does the PolyCSS snapshot export do?

It clones the rendered camera or scene DOM, injects only the CSS the snapshot needs, inlines image assets as base64 data URLs, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. If an asset cannot be inlined it throws a PolySceneSnapshotError with the code ASSET_INLINE_FAILED.

## Sources

- [Issues](https://github.com/layoutit/polycss/issues)
- [layoutit/polycss on GitHub](https://github.com/layoutit/polycss)
- [License: MIT](https://github.com/layoutit/polycss/blob/main/LICENSE)
- [Project website](https://polycss.com)
- [README](https://github.com/layoutit/polycss/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/layoutit-polycss
