Skip to content

Star Motes

Three-layer parallax starfield with twinkle variance and pointer drift. Canvas-free version included for quiet brand sites; WebGL tier for dense scenes.

7.2k
MIT licensev1.3.03.4 KB gzipzero dependenciesby Motif Studioadded 2026-05-30review due 2026-11-26
Live playground — drag, click, hoversandboxed previewsample copy

120 stars · 3 depth layers · 2 draw calls

move the pointer to drift

Ambience
vibes

Ambience is a live light wash over the preview. Full palette re-theming of every demo arrives with Theme Studio (Pro).

deps: zeroJSX + TailwindMIT · original
star-motes.tsx
// Star Motes — the demo's own source, extracted verbatim.
// From src/components/demos/scenes/set-23.tsx, export WebglStarMotes.
// Needs React 18+ and Tailwind CSS v4. The classes below include this site's
// token utilities (bg-panel, text-ink-dim, chip …); their values are served as
// CSS custom properties at /api/exports/tokens.json. The reduced-motion branch
// is part of the source, not an afterthought.
// 17 helpers the demo imports are inlined above it, verbatim.
// Imports three — that dependency is part of the source.

import { useEffect, useRef, useState } from "react";

import { AdditiveBlending, BufferAttribute, BufferGeometry, Mesh, OrthographicCamera, PlaneGeometry, Points, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderer } from "three";

export type DemoProps = Record<string, number | string | boolean>;

export function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState<boolean>(() =>
    typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false
  );
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function num(v: number | string | boolean | undefined, fallback: number): number {
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
}

function rendererFor(canvas: HTMLCanvasElement): WebGLRenderer | null {
  try {
    const r = new WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: "low-power" });
    r.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    return r;
  } catch {
    return null;
  }
}

const QUAD_VERT = `
varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;

const BACKDROP_FRAG = `
precision highp float;
uniform vec3 uInner;
uniform vec3 uOuter;
varying vec2 vUv;

void main() {
  vec2 d = (vUv - vec2(0.5, 1.20)) * vec2(1.0, 0.72);
  float t = clamp(1.0 - length(d) / 0.70, 0.0, 1.0);
  gl_FragColor = vec4(mix(uOuter, uInner, t * t), 1.0);
}
`;

function seeded(i: number): number {
  const x = Math.sin(i * 127.1 + 311.7) * 43758.5453123;
  return x - Math.floor(x);
}

interface StarLayer {
  /** How far the layer travels for a full pointer sweep, in field units. */
  parallax: number;
  /** Point size in CSS pixels, before the device pixel ratio. */
  size: number;
  /** Peak brightness. */
  glow: number;
}

const STAR_LAYERS: readonly StarLayer[] = [
  { parallax: 0.30, size: 2.5, glow: 0.95 },
  { parallax: 0.18, size: 1.7, glow: 0.72 },
  { parallax: 0.09, size: 1.1, glow: 0.52 },
];

const f3 = (v: number) => v.toFixed(3);

function layerFn(name: string, key: keyof StarLayer): string {
  const arms = STAR_LAYERS.map((l, i) =>
    i < STAR_LAYERS.length - 1 ? `  if (layer < ${i + 0.5}) return ${f3(l[key])};` : `  return ${f3(l[key])};`,
  );
  return `float ${name}(float layer) {\n${arms.join("\n")}\n}`;
}

const STAR_VERT = `
uniform float uTime;
uniform float uDpr;
uniform vec2 uShift;
attribute vec3 aSeed;
varying float vGlow;

${layerFn("layerParallax", "parallax")}

${layerFn("layerSize", "size")}

${layerFn("layerGlow", "glow")}

void main() {
  float layer = aSeed.x;
  vec3 p = position;
  p.x += uShift.x * layerParallax(layer);
  p.y += uShift.y * layerParallax(layer);

  // Twinkle: phase and rate both from the star's own seed, so no two are in
  // step and the field never pulses as a block.
  float ph = aSeed.y * 6.2831853;
  float rate = 0.55 + aSeed.z * 1.45;
  vGlow = layerGlow(layer) * (0.26 + 0.74 * (0.5 + 0.5 * sin(uTime * rate + ph)));

  gl_PointSize = layerSize(layer) * uDpr;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`;

const STAR_FRAG = `
uniform vec3 uWarm;
uniform vec3 uCool;
varying float vGlow;

void main() {
  float d = length(gl_PointCoord - 0.5);
  float m = 1.0 - smoothstep(0.20, 0.5, d);
  if (m < 0.004) discard;
  // A four-point flare, so the brightest stars have a shape rather than a
  // smear of dots.
  float flare = max(0.0, 1.0 - abs(gl_PointCoord.x - 0.5) * 9.0)
              * max(0.0, 1.0 - abs(gl_PointCoord.y - 0.5) * 9.0);
  vec3 col = mix(uCool, uWarm, vGlow * vGlow);
  gl_FragColor = vec4(col, (m + flare * 0.22) * vGlow * 0.95);
}
`;

function watchSize(canvas: HTMLCanvasElement, apply: (w: number, h: number) => void): () => void {
  const host = canvas.parentElement ?? canvas;
  let w = 0;
  let h = 0;
  const measure = () => {
    const rect = host.getBoundingClientRect();
    const nw = Math.max(1, Math.round(rect.width));
    const nh = Math.max(1, Math.round(rect.height));
    if (nw === w && nh === h) return;
    w = nw;
    h = nh;
    apply(w, h);
  };
  measure();
  const ro = new ResizeObserver(measure);
  ro.observe(host);
  return () => ro.disconnect();
}

function watchVisible(canvas: HTMLCanvasElement): { visible: () => boolean; stop: () => void } {
  let onScreen = true;
  const io = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; });
  io.observe(canvas);
  return { visible: () => onScreen && !document.hidden, stop: () => io.disconnect() };
}

function StarFieldStill({ n }: { n: number }) {
  const stars = Array.from({ length: n }, (_, i) => {
    const layer = i % STAR_LAYERS.length;
    return {
      x: seeded(i * 3 + 1) * 100,
      y: seeded(i * 3 + 2) * 100,
      s: STAR_LAYERS[layer].size * 0.75,
      o: STAR_LAYERS[layer].glow * (0.45 + seeded(i * 3 + 3) * 0.5),
    };
  });
  return (
    <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_120%,#111c33,transparent_70%),#05070c]">
      {stars.map((s, i) => (
        <span
          key={i}
          className="absolute rounded-full bg-white"
          style={{ left: `${s.x}%`, top: `${s.y}%`, width: s.s, height: s.s, opacity: s.o }}
        />
      ))}
    </div>
  );
}

export function WebglStarMotes({ density = 120 }: DemoProps) {
  const reduced = useReducedMotion();
  const n = Math.round(clamp(num(density, 120), 10, 300));
  const ref = useRef<HTMLCanvasElement>(null);
  const [noGl, setNoGl] = useState(false);

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const renderer = rendererFor(canvas);
    if (!renderer) {
      setNoGl(true);
      return;
    }

    const scene = new Scene();
    const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 10);
    camera.position.z = 1;

    // The sky first: one quad, so the asset needs no CSS behind it.
    const backdropMat = new ShaderMaterial({
      uniforms: {
        uInner: { value: new Vector3(0x11 / 255, 0x1c / 255, 0x33 / 255) },
        uOuter: { value: new Vector3(0x05 / 255, 0x07 / 255, 0x0c / 255) },
      },
      vertexShader: QUAD_VERT,
      fragmentShader: BACKDROP_FRAG,
      depthWrite: false,
      depthTest: false,
    });
    const backdrop = new Mesh(new PlaneGeometry(2, 2), backdropMat);
    backdrop.frustumCulled = false;
    scene.add(backdrop);

    // The stars: one cloud, one draw call, the layer carried per vertex.
    const position = new Float32Array(n * 3);
    const seed = new Float32Array(n * 3);
    for (let i = 0; i < n; i++) {
      position[i * 3] = (seeded(i * 3 + 1) - 0.5) * 2.9;
      position[i * 3 + 1] = (seeded(i * 3 + 2) - 0.5) * 2.9;
      position[i * 3 + 2] = 0;
      seed[i * 3] = i % STAR_LAYERS.length;
      seed[i * 3 + 1] = seeded(i * 3 + 3);
      seed[i * 3 + 2] = seeded(i * 3 + 5);
    }
    const geo = new BufferGeometry();
    geo.setAttribute("position", new BufferAttribute(position, 3));
    geo.setAttribute("aSeed", new BufferAttribute(seed, 3));

    const starMat = new ShaderMaterial({
      uniforms: {
        uTime: { value: 0 },
        uDpr: { value: Math.min(window.devicePixelRatio || 1, 2) },
        uShift: { value: new Vector2(0, 0) },
        uWarm: { value: new Vector3(1.0, 0.97, 0.90) },
        uCool: { value: new Vector3(0.68, 0.78, 0.96) },
      },
      vertexShader: STAR_VERT,
      fragmentShader: STAR_FRAG,
      transparent: true,
      depthWrite: false,
      blending: AdditiveBlending,
    });
    const stars = new Points(geo, starMat);
    // The positions are written once and never read back, and the layer offset
    // lives in the shader, so a bounding sphere would be recomputed per frame
    // for a cloud that cannot leave the frustum.
    stars.frustumCulled = false;
    scene.add(stars);

    const target = { x: 0, y: 0 };
    const eased = { x: 0, y: 0 };
    const onMove = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect();
      target.x = ((e.clientX - r.left) / Math.max(1, r.width) - 0.5) * 2;
      target.y = ((e.clientY - r.top) / Math.max(1, r.height) - 0.5) * 2;
    };
    const onLeave = () => { target.x = 0; target.y = 0; };
    canvas.addEventListener("pointermove", onMove);
    canvas.addEventListener("pointerleave", onLeave);

    const stopSize = watchSize(canvas, (w, h) => {
      renderer.setSize(w, h, false);
    });

    const vis = watchVisible(canvas);
    const draw = (t: number) => {
      starMat.uniforms.uTime.value = t;
      eased.x += (target.x - eased.x) * 0.055;
      eased.y += (target.y - eased.y) * 0.055;
      // Negated on y so the field leans away from the pointer rather than with
      // it, which is the direction that reads as depth.
      starMat.uniforms.uShift.value.set(eased.x, -eased.y);
      renderer.render(scene, camera);
    };

    let raf = 0;
    let start = 0;
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      if (!vis.visible()) return;
      if (!start) start = now;
      draw((now - start) / 1000);
    };

    if (reduced) {
      // One still frame: every star is at its own brightness for t = 0 and the
      // field is centred, so the composition is intact without the drift.
      draw(0);
    } else {
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      stopSize();
      vis.stop();
      canvas.removeEventListener("pointermove", onMove);
      canvas.removeEventListener("pointerleave", onLeave);
      backdrop.geometry.dispose();
      backdropMat.dispose();
      geo.dispose();
      starMat.dispose();
      renderer.dispose();
    };
  }, [reduced, n]);

  return (
    <div className="relative h-full w-full overflow-hidden bg-[#05070c]">
      {noGl ? (
        <StarFieldStill n={n} />
      ) : (
        <canvas
          ref={ref}
          role="img"
          aria-label={`A field of ${n} stars spread over ${STAR_LAYERS.length} depth planes, drifting against the pointer`}
          className="block h-full w-full"
        />
      )}
      <p className="pointer-events-none absolute bottom-2 right-3 rounded bg-black/45 px-1.5 py-0.5 font-mono text-[10px] leading-tight text-white/70">
        {noGl
          ? `${n} stars · still field · no WebGL context`
          : `${n} stars · ${STAR_LAYERS.length} depth layers · 2 draw calls`}
      </p>
      {!noGl && (
        <p className="pointer-events-none absolute left-3 top-2 font-mono text-[10px] text-white/45">
          move the pointer to drift
        </p>
      )}
    </div>
  );
}

Snippet provenance

lines 1–347 · v1.3.0
v1.3.0Motif Studio2026-05-30the only version the catalog records for this asset

No changelog entry mentions “Star Motes”, so the catalog records a single version for it.

Every line above is attributed to the single version this catalog records — we keep no per-line history, so no per-line attribution is invented. See the provenance page for the rule and the gaps.

Review notes · anchored to code lines

Threads attach to a line number in this asset's snippet, so a remark about the focus trap stays next to it.

demo · stored in this browser

Loading notes…

The snippet on this page is 347 lines long, so anchors run 1–347. Notes are per-asset and per-browser; a shared review thread is a server feature, and this panel is the local stand-in that proves the anchoring works.

Dependency-aware copy: zero packages to install — paste and run. All markup is original Motif UI content (MIT).

Course rail — understand it before you ship it

Theme Studio preview

Theming example — two token themes

Assets are token-driven (100% design-token driven). Here the same scene runs under two theme presets from the token sheet.

Midnight / violet

120 stars · 3 depth layers · 2 draw calls

move the pointer to drift

--accent 262 · dark surfaces

Bone / amber

120 stars · 3 depth layers · 2 draw calls

move the pointer to drift

--accent 40 · paper surfaces

90-second read

Usage recipe — where this fits

Use it as the signature of a single screen — a hero backdrop, an entrance, a transition. One signature per screen; everything else stays quiet.

Compose it with calm content around it: the motion earns attention only when the layout does not compete for it.

belongs in animatedscroll behaviour

Cost honesty

Performance note

3.4 KB gzipcompositor-bound — motion runs on transform/opacity layers; the cost scales with layer count and blur radius.

Cheaper alternative — the static preview variant removes the animation loop and keeps the layout — a quieter, cheaper sibling.

What it needs

Browser support strip

CSS transformsopacity compositingcustom propertiesprefers-reduced-motionwill-changeall evergreen browsers

No polyfills ship with the asset — if a listed feature is missing in a target browser, the graceful fallback is the static layout.

prefers-reduced-motion

Reduced-motion fallback

With prefers-reduced-motion: reduce, the animation loop and travel are removed; state changes keep working through opacity and colour (≤ 300ms). The demo you see above honours the switch — try it in your OS settings and the loop will quiet down.

Design rule: the reduced branch is a second design, not a stripped page — every state change stays visible.

Pressable step-through

Keyboard walk demo

Step through what a keyboard user experiences with this asset.

1

Tab to the scene — motion is paused or non-essential for keyboard users

Auto-suggested

Composition map — what pairs well

One original micro-case

Inspiration context

The hero that held attention for one beat longer

A marketing page's hero used a stock fade-in that finished in 200ms — invisible to the eye, useless to the story. Re-timed with a slow settle (0.6s, ease-out-quart) and one typographic wipe, the hero read as 'alive' without a single new asset. The change was purely a curve and a delay.

Written in-house — no screenshots, no borrowed imagery. The pattern, not the pixels.

Every version, why

Changelog — this asset

v1.3.02026-05-30

Current release — this page documents the asset as shipped.

v1.2.02026-08-18

Token pass: colours and radii moved to the design-token sheet; a11y score raised to the current bar.

v0.1.02026-07-02

Initial release to the library — original markup, MIT licensed.

30-day sparkline

Copy history

7,150copies this month

Sample series from this asset's usage fingerprint · last 12 days shown.

Future queue

Community remixes

Queue opens after launch. Remixes arrive as alternate versions of this asset — same job, different voice — submitted by users and audited by the studio before they appear beside the original.

0 remixes yetaudited by studiocredit + link back to the remixer

Star / favourite

Save this asset

Your collection is stored in this browser (localStorage) — no account needed.

Build a page around it

Pairs well with a prompt

This asset belongs inside a bigger build — here is the prompt that would generate a page containing it:

Motion-spec page — the animation before the layout

Spec sheet · Timing table · Easing curves

beta

Exact tree

Bundled dependencies — disclosed

star-motes.tsx11.1 KB raw · ~1 KB gzip
(no runtime dependencies)the CSS ships with the snippet

Full snippet ≈ 11.1 KB raw, ~1 KB gzip — before any framework you already load.

Download

Export as single file

Grab the asset as one file, ready to paste into your project.

Files are generated in your browser from the snippets on this page — nothing leaves the tab.

Scale, not clutter

Sizing system — three densities

Density is a token decision, not a per-page override — pick one density per product surface and hold it. Active: Default.

Runnable copy

Open in a sandbox

Opens this asset in a new tab with its stylesheet injected — a real, editable HTML document in your browser. No account, no upload.

If the tab is blocked, use the single-file export instead — same content, saved locally.

Written version first

Tutorial transcript

This asset does not have a video yet — so the written version stands in: the code line walk below narrates the snippet line by line, and the design rationale on this page explains the default decisions. When a video ships, this exact panel hosts its full transcript.

video: not yettranscript: line walk belowformat: markdown, searchable

Annotated snippet

Code line walk

The lines that matter, with the reason they matter. Click one:

Trade-off, visible

Size vs. quality slider

~2 KB~6 KB~12 KB

Standard · CSS + hover states

~6 KB

adds transitions, focus-visible and reduced-motion rules

The slider is honest: the slim tier has no JS, the pro tier pays for behaviour. Start slim, upgrade only the surfaces that need it.

HTML → React, priced

Stack-switch cost note

Taking the HTML/CSS version into a React component costs real work — and the cost is predictable:

Event wiring

Hover, focus and click become handlers — the CSS stays, the state appears.

Props for the knobs

Every inline value that should vary becomes a prop with a default — the defaults must match the CSS exactly.

The estimate

A clean port of this asset is 20–45 minutes for a React developer — the 0-character stylesheet is the easy half.

Find it the way you say it

Tag synonyms

Search on this site resolves synonyms globally — "dropdown", "select" and "picker" land on the same asset, so you never need to know the catalog's internal vocabulary.

When to pick which

Sibling comparison

Data table — data table
CompareStar MotesAurora VeilHalo Trail
KindAnimatedAnimatedAnimated
Bundle3.4 KB gzip2.6 KB gzip5.8 KB gzip
a11y97/100100/10093/100
Dependenciesnonenonenone
Copies / mo7,1508,9406,330
Interactionsscrollscrolldrag, hover

The honest verdict — Aurora Veil is lighter, but Star Motes carries richer behaviour — pick by the interaction your screen actually needs, then swap tokens, not pages.

Read next — two guides and a prompt

picked by tag overlap, not by hand

How these three were chosen: essays scored on shared tags (0 and 0 matches), then prompt scored on block/vibe overlap (0 matches). The rule lives in src/lib/spine.ts, so the same asset always links to the same three pages and a second reader can reproduce the choice.

–remixes · this browser

The counter is a note to yourself — it adds one each time you tap, in motif:remix-count:v1, and goes nowhere. A real, public remix count would need a server and accounts; this site has neither, and it would rather say that than print a number it cannot own.

Was this helpful?