Install
That writes one file:compositions/spiral-galaxy.html.
Add it to your video
It runs for 10 seconds at 1920×1080. Paste this into your composition:index.html
<div
data-composition-id="spiral-galaxy"
data-composition-src="compositions/spiral-galaxy.html"
data-start="0"
data-duration="10"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
data-start. Put it on a different timeline row with
data-track-index. See data attributes for the rest.
Change how it looks
Set these CSS variables on the block:stars— Star count. Defaults to20000.arms— Arm count. Defaults to3.rate— Rotation rate. Defaults to0.25.glow— Core brightness. Defaults to1.9.size— Star size. Defaults to10.core— Core colour. Defaults to#ffa575.rim— Rim colour. Defaults to#311599.
Variables
Every one of these has a default, so the piece works untouched. Set the ones you want to change on the element:| Variable | Default | Accepts | What it does |
|---|---|---|---|
stars | 20000 | 2000 to 40000, step 1000 | |
arms | 3 | 2 to 8, step 1 | |
rate | 0.25 | 0rad/s to 1.5rad/s, step 0.05rad/s | |
glow | 1.9 | 0.2 to 4, step 0.1 | |
size | 10 | 2px to 40px, step 1px | |
core | #ffa575 | color | |
rim | #311599 | color |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="spiral-galaxy"
data-composition-src="compositions/spiral-galaxy.html"
data-variable-values='{"stars":20000,"arms":3,"rate":0.25,"glow":1.9,"size":10,"core":"#ffa575","rim":"#311599"}'
></div>
Source
spiral-galaxy.html
spiral-galaxy.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Spiral Galaxy</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #03040a;
}
#sg-root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/* Full-bleed child carries the scene fill; never the composition root. */
#sg-fill {
position: absolute;
inset: 0;
background: #03040a;
}
#sg-gl {
position: absolute;
inset: 0;
display: block;
width: 1920px;
height: 1080px;
}
/* Ambient halo in the rim colour, screen-blended over the point cloud.
Reads the declared `rim` variable. Variable ids are single lowercase
words on purpose: the runtime injects `--<id>` while the compiler
injects `--<slugify(id)>`, and those two only agree when the id has
no capitals or separators. */
#sg-halo {
position: absolute;
inset: 0;
mix-blend-mode: screen;
opacity: 0.12;
background: radial-gradient(72% 46% at 50% 50%, var(--rim, #311599) 0%, #000000 70%);
}
</style>
</head>
<body>
<div
id="sg-root"
data-composition-id="spiral-galaxy"
data-composition-variables='[
{"id":"stars","type":"number","label":"Star count","default":20000,"min":2000,"max":40000,"step":1000},
{"id":"arms","type":"number","label":"Arm count","default":3,"min":2,"max":8,"step":1},
{"id":"rate","type":"number","label":"Rotation rate","default":0.25,"min":0,"max":1.5,"step":0.05,"unit":"rad/s"},
{"id":"glow","type":"number","label":"Core brightness","default":1.9,"min":0.2,"max":4,"step":0.1},
{"id":"size","type":"number","label":"Star size","default":10,"min":2,"max":40,"step":1,"unit":"px"},
{"id":"core","type":"color","label":"Core colour","default":"#ffa575"},
{"id":"rim","type":"color","label":"Rim colour","default":"#311599"}
]'
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
>
<div id="sg-fill"></div>
<canvas id="sg-gl" width="1920" height="1080"></canvas>
<div id="sg-halo"></div>
<!-- Driver clip: gives the block a timed element for the host timeline. -->
<div
id="sg-drv"
class="clip"
data-start="0"
data-duration="10"
data-track-index="0"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
></div>
</div>
<script>
(function () {
var COMP_ID = "spiral-galaxy";
var DURATION = 10;
var W = 1920,
H = 1080;
// ── Measured constants ────────────────────────────────────────────
// Read from the three.js galaxy examples (MIT) during reference
// research; reimplemented here, nothing copied.
// count 20000 · branches 3 · radius = ratio^1.5 * 5 · spin 1
// angle = branchAngle + spin * radius + t * (1 - radiusRatio)
// randomnessPower 3 · inside #ffa575 · outside #311599
var DISC_RADIUS = 5.0; // world units, from `radius = ratio^1.5 * 5`
var RADIUS_POWER = 1.5; // ratio -> radius exponent
// Static Archimedean twist, radians per world unit. This is what draws
// the arm across the WHOLE disc; the time term below then winds it.
// Slightly under the example's 1.0 so the outer arms stay open.
var SPIN = 0.78;
var RANDOMNESS = 0.2; // arm thickness, as a fraction of DISC_RADIUS
var RANDOM_POWER = 3.0; // pow(u, 3): most stars hug the arm, a few stray
var TANGENT_SQUEEZE = 0.62; // arms are thin across, long along
var DISC_FLATNESS = 0.3; // vertical jitter relative to in-plane jitter
var SEED = 20240817; // fixes every per-star attribute
// ── Variables ─────────────────────────────────────────────────────
// Read ONCE at init. Variables never change mid-render.
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function clampNum(value, lo, hi, fallback) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.min(hi, Math.max(lo, n));
}
var STARS = Math.round(clampNum(vars.stars, 2000, 40000, 20000));
var ARMS = Math.round(clampNum(vars.arms, 2, 8, 3));
var RATE = clampNum(vars.rate, 0, 1.5, 0.25);
var GLOW = clampNum(vars.glow, 0.2, 4, 1.9);
var SIZE = clampNum(vars.size, 2, 40, 10);
var CORE_COLOR = typeof vars.core === "string" ? vars.core : "#ffa575";
var RIM_COLOR = typeof vars.rim === "string" ? vars.rim : "#311599";
// ── Seeded PRNG (mulberry32) ──────────────────────────────────────
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── Per-star attributes, seeded once ──────────────────────────────
// Nothing here depends on time. Every frame is computed from these
// plus `uTime` alone, so frame N never reads frame N-1.
var rng = mulberry32(SEED);
var aRatio = new Float32Array(STARS); // 0 = core, 1 = rim
var aBranch = new Float32Array(STARS); // base arm angle
var aJitterR = new Float32Array(STARS); // offset along the arm's radial axis
var aJitterT = new Float32Array(STARS); // offset along the arm's tangent
var aJitterY = new Float32Array(STARS); // disc thickness
var aSpark = new Float32Array(STARS); // per-star size variation
function jitter() {
var magnitude = Math.pow(rng(), RANDOM_POWER) * RANDOMNESS * DISC_RADIUS;
return rng() < 0.5 ? -magnitude : magnitude;
}
for (var i = 0; i < STARS; i++) {
aRatio[i] = rng();
aBranch[i] = ((i % ARMS) / ARMS) * Math.PI * 2;
aJitterR[i] = jitter();
aJitterT[i] = jitter() * TANGENT_SQUEEZE;
aJitterY[i] = jitter() * DISC_FLATNESS;
aSpark[i] = rng();
}
// ── Scene ─────────────────────────────────────────────────────────
var renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("sg-gl"),
antialias: false,
alpha: false,
preserveDrawingBuffer: true, // survives seek-capture screenshots
});
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
renderer.setClearColor(0x03040a, 1);
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 200);
camera.position.set(0, 2.9, 7.6);
camera.lookAt(0, 0, 0);
var uniforms = {
uTime: { value: 0 },
uRate: { value: RATE },
uSpin: { value: SPIN },
uRadius: { value: DISC_RADIUS },
uPower: { value: RADIUS_POWER },
uSize: { value: SIZE },
uRefDist: { value: camera.position.length() },
uGlow: { value: GLOW },
uCore: { value: new THREE.Color(CORE_COLOR) },
uRim: { value: new THREE.Color(RIM_COLOR) },
};
var VERT = [
"attribute float aRatio;",
"attribute float aBranch;",
"attribute float aJitterR;",
"attribute float aJitterT;",
"attribute float aJitterY;",
"attribute float aSpark;",
"uniform float uTime;",
"uniform float uRate;",
"uniform float uSpin;",
"uniform float uRadius;",
"uniform float uPower;",
"uniform float uSize;",
"uniform float uRefDist;",
"uniform float uGlow;",
"uniform vec3 uCore;",
"uniform vec3 uRim;",
"varying vec3 vColor;",
"varying float vBright;",
"void main() {",
" float ratio = clamp(aRatio, 0.0, 1.0);",
// Differential rotation. omega falls to zero at the rim, so the
// core laps the outer disc and the arms wind up over time.
// Solved directly from uTime — never integrated frame to frame.
" float omega = 1.0 - ratio;",
" float r = pow(ratio, uPower) * uRadius;",
// uSpin * r is the static arm shape (time-independent, so it drops
// out of any inner-vs-outer rotation measurement); uRate * uTime *
// omega is the winding.
" float theta = aBranch + uSpin * r + uRate * uTime * omega;",
" vec2 radial = vec2(cos(theta), sin(theta));",
" vec2 tangent = vec2(-radial.y, radial.x);",
// Jitter rides in the star's own rotating frame, so arm thickness
// holds its shape instead of smearing as the disc turns.
" vec2 xz = radial * (r + aJitterR) + tangent * aJitterT;",
" vec4 mv = modelViewMatrix * vec4(xz.x, aJitterY, xz.y, 1.0);",
" gl_Position = projectionMatrix * mv;",
" gl_PointSize = uSize * (uRefDist / max(-mv.z, 0.001)) * (0.55 + 0.75 * aSpark);",
// Lerp by r/R (i.e. ratio^1.5), matching the example's colour ramp:
// the disc stays warm well past mid-radius and only the rim goes cold.
" vColor = mix(uCore, uRim, pow(ratio, uPower));",
" vBright = uGlow * (0.22 + 0.78 * exp(-ratio * 3.0));",
"}",
].join("\n");
var FRAG = [
"varying vec3 vColor;",
"varying float vBright;",
"void main() {",
" float d = length(gl_PointCoord - vec2(0.5));",
" float a = smoothstep(0.5, 0.0, d);",
" a = a * a;",
" gl_FragColor = vec4(vColor * vBright, a);",
"}",
].join("\n");
var geometry = new THREE.BufferGeometry();
// The vertex shader builds every position from the attributes below;
// `position` exists only because three.js counts vertices from it.
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(STARS * 3), 3),
);
geometry.setAttribute("aRatio", new THREE.BufferAttribute(aRatio, 1));
geometry.setAttribute("aBranch", new THREE.BufferAttribute(aBranch, 1));
geometry.setAttribute("aJitterR", new THREE.BufferAttribute(aJitterR, 1));
geometry.setAttribute("aJitterT", new THREE.BufferAttribute(aJitterT, 1));
geometry.setAttribute("aJitterY", new THREE.BufferAttribute(aJitterY, 1));
geometry.setAttribute("aSpark", new THREE.BufferAttribute(aSpark, 1));
var material = new THREE.ShaderMaterial({
uniforms: uniforms,
vertexShader: VERT,
fragmentShader: FRAG,
blending: THREE.AdditiveBlending,
transparent: true,
depthTest: false,
depthWrite: false,
});
var points = new THREE.Points(geometry, material);
points.frustumCulled = false; // positions live in the shader, not in `position`
scene.add(points);
function draw(t) {
uniforms.uTime.value = t;
renderer.render(scene, camera);
}
// ── Timeline ──────────────────────────────────────────────────────
// `tl.eventCallback("onUpdate", ...)` does NOT fire on tl.seek(), so
// the repaint hangs off a property setter on the tweened driver —
// that setter runs on every render, seek included.
var driver = { _t: 0 };
Object.defineProperty(driver, "t", {
get: function () {
return this._t;
},
set: function (value) {
this._t = value;
draw(value);
},
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// ease "none" over the full duration makes driver.t === tl.time().
tl.to(driver, { t: DURATION, duration: DURATION, ease: "none", lazy: false }, 0);
window.__timelines[COMP_ID] = tl;
draw(0); // paint frame 0; GSAP skips the setter when the value is unchanged
})();
</script>
</body>
</html>
hero space particles webgl background.