Voxel

A tiny 3D voxel renderer you steer with signals. No WebGL.

zweiundeins <sb-voxel> 3d canvas animation pixel model Since 2026-09-21 MIT licence 3.2 kB0 Open in playground Edit on GitHub

Playground

A software 3D renderer for voxel models: a rocket, a satellite and a ringed planet. It rasterises into a 128×128 buffer with a z-buffer, uses flat, stepped shading and a pixel outline, and is scaled up with crisp edges. Drag it or use the arrow keys to orbit. Every angle is an attribute, so Datastar signals can steer it: the user's orbit is local, and the next angle the page sends replaces it.

Examples

Steered by sliders

Each slider is bound to a local signal, and each signal drives an attribute with data-attr. data-preserve-attr keeps those attributes when a server frame morphs the page. Drag the model and the sliders follow when you let go: sb-orbit reports the angles, and writing them back into the signals is safe, because a new angle only replaces the user's turn with the same one.

<div data-signals="{_yaw: 30, _pitch: 15, _zoom: 1, _light: 45}" style="display: flex; flex-wrap: wrap; gap: 24px; align-items: center; inline-size: 100%">
  <sb-voxel
    data-attr:yaw="$_yaw" data-attr:pitch="$_pitch" data-attr:zoom="$_zoom" data-attr:light="$_light"
    data-preserve-attr="yaw pitch zoom light"
    data-on:sb-orbit="$_yaw = Math.round(evt.detail.yaw); $_pitch = Math.round(evt.detail.pitch)"></sb-voxel>
  <div style="display: grid; gap: 16px; flex: 1; min-inline-size: 14rem">
    <sb-slider label="Yaw" min="-180" max="180" unit="°" data-bind:_yaw__prop.value></sb-slider>
    <sb-slider label="Pitch" min="-89" max="89" unit="°" data-bind:_pitch__prop.value></sb-slider>
    <sb-slider label="Zoom" min="0.25" max="2.5" step="0.05" data-bind:_zoom__prop.value></sb-slider>
    <sb-slider label="Light" min="0" max="360" step="5" unit="°" data-bind:_light__prop.value></sb-slider>
  </div>
</div>

Models

<sb-voxel model="rocket" spin="30" style="--sb-voxel-size: 10rem"></sb-voxel>
<sb-voxel model="satellite" spin="-20" pitch="25" style="--sb-voxel-size: 10rem"></sb-voxel>
<sb-voxel model="planet" spin="15" pitch="20" style="--sb-voxel-size: 10rem"></sb-voxel>

Report the orbit

When the user has turned the model, sb-orbit reports the effective angles: once when the drag ends or the arrow key is released, not for a plain click.

<div data-signals:_orbit="'drag the satellite'">
  <sb-voxel model="satellite" data-on:sb-orbit="$_orbit = 'yaw ' + Math.round(evt.detail.yaw) + '°, pitch ' + Math.round(evt.detail.pitch) + '°'"></sb-voxel>
  <p data-text="$_orbit"></p>
</div>

Performance

It only paints when something changes: a prop, a drag, or each frame while spin is set. Offscreen instances pause, and with prefers-reduced-motion the model doesn't spin.

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Size: --sb-voxel-size (default 16rem) sets the width; it stays square and never grows wider than its container.
  • Colours: the models have their own colours, which themes don't change. The canvas is transparent, so it sits on whatever is behind it; the focus ring is --sb-brand-light.
  • Parts: canvas. Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-voxel { --sb-voxel-size: 10rem; }
  .my-voxel::part(canvas) { background: radial-gradient(var(--sb-brand-subtle), transparent 70%); }
</style>
<sb-voxel class="my-voxel" model="planet" spin="30"></sb-voxel>

Accessibility

The canvas has role="img" and a label with the model and its current angles; while it spins, the label says "spinning" instead of changing every frame. It is focusable, its description says it can be turned, and the arrow keys orbit (Shift for bigger steps).

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-voxel spin="40"></sb-voxel>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/voxel@fce591c72b5d/voxel.bundle.min.js" integrity="sha384-dWqJptdOflQyNPJ45SmDx+//B7Smf5VRcS3NFXIvHLUiMD500xe57MsU44yD09ar"></script>

<sb-voxel spin="40"></sb-voxel>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/voxel@fce591c72b5d/voxel.min.js": "sha384-SMJbcp3VePz4A8sttKz2wk6F4DA6KXTKma2zugJk5z+Gr1NHxuzkmdBlFNtfEG3D"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/voxel@fce591c72b5d/voxel.min.js" integrity="sha384-SMJbcp3VePz4A8sttKz2wk6F4DA6KXTKma2zugJk5z+Gr1NHxuzkmdBlFNtfEG3D"></script>

<sb-voxel spin="40"></sb-voxel>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/voxel@fce591c72b5d/voxel.bundle.min.js": "sha384-dWqJptdOflQyNPJ45SmDx+//B7Smf5VRcS3NFXIvHLUiMD500xe57MsU44yD09ar"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-voxel spin="40"></sb-voxel>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-voxel>
    • voxel.bundle.min.js one file 3.2 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/voxel/voxel.bundle.min.js"></script>

<sb-voxel spin="40"></sb-voxel>

Or copy the module files, keeping each component's folder, and load each component's module:

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/voxel/voxel.min.js"></script>

<sb-voxel spin="40"></sb-voxel>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
voxel.bundle.min.js 6.7 kB3.5 kB3.2 kB
As module files: one request, 3.1 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
voxel.js 12.7 kB5.3 kB4.6 kB3.1 kB

API reference

Props

AttributeTypeDefaultDescription
model"rocket" | "satellite" | "planet""rocket"Which voxel model to show.
yawnumber30Rotation around the vertical axis, in degrees.
pitchnumber15Tilt toward the viewer, in degrees.
zoomnumber1Scale factor.
spinnumber0Auto-rotation speed, in degrees per second.
lightnumber45Light direction around the model, in degrees.

Events

NameDescription
sb-orbitAfter a drag or an arrow-key turn. detail: { yaw, pitch } (effective angles).