Three.js in the hero: color, motion, and just enough wow


This was the first time I had used Three.js in a production environment, and I was excited to see how it would perform. Plus we delivered this site very quickly, and we wanted something that was going to wow our student audience and make an exciting first impression.

The first pass is the most “classic Three.js intro”: thousands of points with vertex colors, two translucent wireframe tori, slow rotation, and a little mouse parallax on the camera. It ships as an Astro component with a <canvas> and an inline module that listens for astro:page-load / astro:before-preparation so ClientRouter navigations do not leak WebGL contexts.
Color is deliberately simple—three buckets, roughly equal odds:
const colorType = Math.random()
if (colorType < 0.33) {
// violet
} else if (colorType < 0.66) {
// magenta / pink
} else {
// blue
}Then PointsMaterial with vertexColors: true, transparent: true, and THREE.AdditiveBlending. Additive blending is the cheap “neon” trick: overlapping particles brighten instead of muddying, which is why a sparse field still feels rich.
Motion stays slow on purpose (time += 0.001, particle spin at time * 0.3). Fast particle fields read as screensaver; slow ones read as atmosphere.
Pride needed the Gilbert Baker rainbow without turning the hero into a candy stripe. Trails pick from seven hex colors, but yellow is down-weighted so it does not dominate:
const TRAIL_COLOR_WEIGHTS = [1, 1, 0.3, 1, 1, 1, 1] // color3 (yellow) quieterThe animation runs from the bottom pushing toward the screen center then lifts up and bubbles off each line. I felt this was inspirational for the Pride Summit brand, and it was a fun challenge to implement. That single array does more for the mix than any post-process grade. Bright yellows scream; fewer of them lets reds, blues, and violets carry the emotion.

On top of the geometry: UnrealBloomPass, high exposure/brightness uniforms, a soft foreground blur so the lower third of the frame stays readable under the glass card, and a MAX_DPR of 1.25 so Retina machines do not pay 2×–3× for glow they will not notice.
Shader credit for the trail concept goes to Sabo Sugi; the site work is the integration—palette, bloom tuning, React lifecycle, and fallbacks.
Fresh off the Pride Summit, I again wanted to create a visually interesting animation that would be a good fit for the DevFest brand. The logo is a circle maze of lines, and I wanted to create an animation that takes influence from the lines but from a different angle and experience. The UM colors were also an influence, as many of our members are from Dearborn and UM main campus in Ann Arbor.
DevFest’s hero is a full-screen fragment shader (again from a Sabo Sugi starting point): two gyroid layers twisted at different speeds inside a soft core, with color mixed in space, not as a flat gradient:
float mixFactor1 = sin(p.y * 2.0 + p.x * 1.5 - t) * 0.5 + 0.5;
vec3 rayColor = mix(uColor1, uColor2, mixFactor1);
float mixFactor2 = sin(p.z * 3.0 + t * 2.0) * 0.5 + 0.5;
rayColor = mix(rayColor, uColor3, mixFactor2);Three brand-ish stops (#0084ff, a muted gold, #ffdd00) breathe because the mix factors depend on position and time. Opposite layer twist speeds (l1TwistSpeed: 0.2 vs l2TwistSpeed: -0.4) keep the pattern from looking like a single spinning blob.
Config lives in a plain object with a Scene Settings panel in dev—so art direction is knobs, not a recompile.
Shipping a WebGL hero without a live control surface means every visual change is a rebuild cycle. DevFest keeps a Scene Settings panel (lil-gui) on the right edge in development—and strips it from production—so design, animation, and performance knobs stay editable while you stare at the real canvas.

The panel is grouped the way you actually decide:
Defaults still live in a plain config object. The GUI mutates that object in place; when a look lands, you copy the values back into source and commit. Production never mounts the panel—visitors get the tuned scene, not a debug HUD.
That workflow is why the “color tricks” and “movement tricks” below are not abstract advice. They are the dials we actually turned until the hero felt like the brand.
1. Cap the palette, then weight it.
Three stops (HackMI / DevFest) or seven with unequal weights (Pride) beat a full rainbow dump. Pick the emotional anchors; demote the loudest hue.
2. Prefer additive / bloom over brighter hexes.
Pushing #ff0 harder usually looks worse. Additive particles and a soft bloom pass make mid colors feel luminous without clipping to white.
3. Mix in the shader, not in the CSS.
Spatial mix() driven by sin(position ± time) keeps color living inside the form. A CSS overlay gradient on top of a grayscale canvas always looks pasted on.
4. Leave a dark ground.
All three heroes sit on near-black. Dark canvas + additive light = readable white type. Light canvas + bloom = washed-out mush.
5. Grade after accumulate.
DevFest’s smoothstep + slight pow on the accumulated glow, plus a vignette, is the difference between “raw SDF” and “poster still.”
Slow the clock.
Pride’s speedMultiplier: 0.1 and HackMI’s tiny time increments matter more than geometry complexity. If you can count frames of change, it is too fast for a hero.
Layer opposing speeds.
Two tori (HackMI) or two gyroid layers twisting opposite ways (DevFest) create interference the eye reads as richness. One oscillator looks mechanical.
Parallax the camera, not the copy.
HackMI nudges camera.position from normalized mouse coords and lookAts the origin. The type stays planted; the world moves under it.
Pause without time jumps.
When you stop requestAnimationFrame, THREE.Clock keeps wall-clock time. DevFest accumulates elapsedTime += clock.getDelta() and discards one delta on resume so the shader does not leap forward after a tab hide or scroll-away. That detail is the difference between “paused” and “glitched.”
Gate the loop hard.
Shared pattern across all three:
prefers-reduced-motion: reduce → static frame or imageIntersectionObserver → stop when the hero leaves the viewportvisibilitychange → stop when the tab is hiddenReact mounts go further: dynamic import() of the scene module, dispose() on unmount, and a provider (HeroAnimationProvider) that centralizes reduced-motion, narrow viewport, and mobile-nav-open so the canvas never fights the menu.
| Concern | Astro (HackMI) | React (Pride / DevFest) |
|---|---|---|
| Mount | astro:page-load + microtask catch-up | useEffect + dynamic import |
| Teardown | astro:before-preparation cleanup | effect cleanup → scene.dispose() |
| Playback API | local isPaused + button | create*Scene().setPlaying() + context |
| Fallback | single rendered frame | <picture> static hero art |
Framework choice did not change the art direction. It changed where lifecycle and accessibility policy live. If you are starting fresh on React, prefer the Pride/DevFest shape: scene factory returns { setPlaying, dispose }, UI owns policy.
A beautiful hero that drains a laptop fan during a sponsor pitch is a failure. Non-negotiables from these builds:
Math.min(devicePixelRatio, 1.25) on the heavy scenes; 2.0 max on the lighter particle field)Skip it if the brand is photography-first, if the hero must work entirely offline in email screenshots, or if the team cannot own the performance budget. A strong full-bleed photograph plus careful type still beats a mid shader. Use WebGL when motion is part of the identity—hackathons, summits, festivals—where energy is the product.
Related portfolio context: Pro-bono web work with Compass and the Michigan DevFest 2026 launch.
If you are wiring a similar hero: start with three colors, one slow clock, a DPR cap, and a static fallback. Add bloom and custom shaders only after that skeleton already feels like the brand.