Nebula

A drifting WebGL nebula in dithered pixels. Speed, density and palette are attributes.

mbolli <sb-nebula> webgl shader background space animation Since 2026-09-22 MIT licence 2.9 kB2 Open in playground Edit on GitHub

Playground

Clouds of gas and a few twinkling stars, rendered by a fragment shader. The shader draws domain-warped noise at low resolution and posterizes it with an ordered dither, so it looks like pixel art. Colours come from theme tokens, and the nebula leans toward the pointer.

Examples

Controls

<div data-signals="{_speed: 1, _density: 0.5, _pixel: 4}" style="display: grid; gap: 16px; inline-size: 100%">
  <sb-nebula data-attr:speed="$_speed" data-attr:density="$_density" data-attr:pixel="$_pixel" data-preserve-attr="speed density pixel"></sb-nebula>
  <div style="display: flex; gap: 24px; flex-wrap: wrap">
    <sb-slider label="Speed" max="5" step="0.1" style="flex: 1; min-inline-size: 10rem" data-bind:_speed__prop.value></sb-slider>
    <sb-slider label="Density" max="1" step="0.05" style="flex: 1; min-inline-size: 10rem" data-bind:_density__prop.value></sb-slider>
    <sb-slider label="Pixel size" min="1" max="16" style="flex: 1; min-inline-size: 10rem" data-bind:_pixel__prop.value></sb-slider>
  </div>
</div>

Palettes

palette picks the colours and seed the clouds. Any number is a seed; from 101 on the skies repeat (101 is the same as 0).

<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 12px; inline-size: 100%">
  <sb-nebula palette="violet" style="--sb-nebula-height: 7rem"></sb-nebula>
  <sb-nebula palette="aurora" seed="7" style="--sb-nebula-height: 7rem"></sb-nebula>
  <sb-nebula palette="ember" seed="3" density="0.7" style="--sb-nebula-height: 7rem"></sb-nebula>
  <sb-nebula palette="mono" seed="12" style="--sb-nebula-height: 7rem"></sb-nebula>
</div>

Smooth

levels="0" turns off the posterizing, and pixel="1" renders at full resolution. These are the only switches: the pixels are the design, so the smooth style (--sb-notch: 0, data-sb-style="smooth") leaves the nebula as it is.

<sb-nebula levels="0" pixel="2" speed="0.6" style="--sb-nebula-height: 9rem"></sb-nebula>

Decoration with a pause switch

label="" makes it decoration, hidden from assistive technology. Motion that goes on for more than five seconds next to other content needs a way to stop it: here a switch sets speed to 0.

<div data-signals:_paused="false" style="display: grid; gap: 12px; inline-size: 100%">
  <sb-nebula label="" data-attr:speed="$_paused ? 0 : 1" data-preserve-attr="speed" style="--sb-nebula-height: 8rem"></sb-nebula>
  <sb-toggle label="Pause the nebula" data-bind:_paused__prop.checked></sb-toggle>
</div>

Performance

One full-screen triangle and a single shader, rendered at a quarter of the CSS resolution by default (pixel="4"). It stops animating offscreen and when speed is 0. Each nebula has its own WebGL context, and frees it when removed. Browsers keep only so many contexts live on a page (Chrome about 16) and take the oldest away beyond that; such a context doesn't come back, while one lost to a GPU reset does. Without WebGL, or once its context is gone, it shows a still --sb-brand glow. Set the height with --sb-nebula-height.

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: it fills the width it is given; --sb-nebula-height (default 14rem) sets the height.
  • Colours: the cloud is painted from theme tokens, read whenever it paints: every frame while it drifts, and at once on a theme change (a pick on <sb-theme-switch>, or the system turning light or dark), even when it is still. An override in your own CSS shows with the next paint. palette picks them: violet uses --sb-brand, --sb-accent and --sb-text-1; aurora --sb-accent, --sb-datastar and --sb-text-1; ember --sb-danger, --sb-warn and --sb-text-1; mono --sb-border-strong, --sb-text-muted and --sb-text-1. All of them sit on --sb-surface-inset. On a light theme, where --sb-surface-inset is lighter than --sb-text-1, it sorts the colours by lightness, so the sky stays dark and the stars light. Without WebGL, or with its context lost, it shows a still --sb-brand glow instead. Corners are --sb-radius.
  • Parts: canvas. Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-nebula { --sb-nebula-height: 8rem; --sb-radius: 0; --sb-brand: #EC4899; }
</style>
<sb-nebula class="my-nebula"></sb-nebula>

Accessibility

It is an image named "Animated nebula"; change the name with label. Where it is only decoration, set label="": it is then hidden from assistive technology. Under prefers-reduced-motion it shows one still frame and ignores the pointer. Otherwise it drifts for as long as it is on screen, so next to other content give people a way to pause it (WCAG 2.2.2): speed="0" holds it still, as in the pause switch above.

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-nebula></sb-nebula>

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/nebula@e5ce9afb093f/nebula.bundle.min.js" integrity="sha384-a2+B8/IsF91gFHeqx/yvzOfXeo63ck6zjem8jEKS5uAa8aL38xg+MTB1yMbWQC6w"></script>

<sb-nebula></sb-nebula>

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/nebula@e5ce9afb093f/nebula.min.js": "sha384-8n1W3foks1f8t7e2u8v1NmRJYgAbJWgD3qfEE+koMDIOFiKyqRQQtQOLOA4FCQEM"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/nebula@e5ce9afb093f/nebula.min.js" integrity="sha384-8n1W3foks1f8t7e2u8v1NmRJYgAbJWgD3qfEE+koMDIOFiKyqRQQtQOLOA4FCQEM"></script>

<sb-nebula></sb-nebula>

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/nebula@e5ce9afb093f/nebula.bundle.min.js": "sha384-a2+B8/IsF91gFHeqx/yvzOfXeo63ck6zjem8jEKS5uAa8aL38xg+MTB1yMbWQC6w"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-nebula></sb-nebula>

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-nebula>
    • nebula.bundle.min.js one file 2.9 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/nebula/nebula.bundle.min.js"></script>

<sb-nebula></sb-nebula>

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/nebula/nebula.min.js"></script>

<sb-nebula></sb-nebula>

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
nebula.bundle.min.js 6.6 kB3.2 kB2.9 kB
As module files: one request, 2.9 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
nebula.js 10.5 kB4.5 kB4.0 kB2.9 kB

API reference

Props

AttributeTypeDefaultDescription
speednumber1Drift speed (0 is still).
densitynumber0.5How much of the sky the gas covers, 0 to 1.
palette"violet" | "aurora" | "ember" | "mono""violet"Colour scheme (from theme tokens).
pixelnumber4Size of one pixel, in CSS pixels.
levelsnumber6Colour levels per channel, dithered (0 for smooth).
starsbooleantrueDraw twinkling stars.
seednumber0Picks a different nebula.
parallaxbooleantrueShift with the pointer.
labelstring"Animated nebula"Accessible label.