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(default16rem) 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.jsone 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.
| File | Original | gzip | brotli |
|---|---|---|---|
voxel.bundle.min.js | 6.7 kB | 3.5 kB | 3.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.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
voxel.js | 12.7 kB | 5.3 kB | 4.6 kB | 3.1 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
model | "rocket" | "satellite" | "planet" | "rocket" | Which voxel model to show. |
yaw | number | 30 | Rotation around the vertical axis, in degrees. |
pitch | number | 15 | Tilt toward the viewer, in degrees. |
zoom | number | 1 | Scale factor. |
spin | number | 0 | Auto-rotation speed, in degrees per second. |
light | number | 45 | Light direction around the model, in degrees. |
Events
| Name | Description |
|---|---|
sb-orbit | After a drag or an arrow-key turn. detail: { yaw, pitch } (effective angles). |