Three.js Scroll Crystal Bloom — GSAP Gem Cluster Reveal
Three.js Scroll Crystal Bloom · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
How to Build a Scroll-Triggered Crystal Bloom With Three.js and GSAP

The Three.js Scroll Crystal Bloom snippet grows a cluster of gem-like shards out of a single seed crystal as the visitor scrolls — one small IcosahedronGeometry sits at the core, and roughly twenty elongated shards scale up around it in a staggered sequence, each catching light differently as MeshPhysicalMaterial refracts and reflects across its facets. The whole bloom is driven by one scrubbed number from GSAP's ScrollTrigger, exactly like the scrub pattern used in the scroll tunnel snippet, just applied to per-object scale instead of camera position.
Stretched icosahedra as crystal spikes
Rather than modeling custom gem geometry, each shard starts as a plain THREE.IcosahedronGeometry and gets non-uniformly scaled — squeezed on X and Z, stretched on Y — via geometry.scale(0.55, 1.6, 0.55) before it's ever added to the scene. A low-detail icosahedron already has flat triangular faces that catch light like facets; stretching it along one axis turns that same base shape into something that reads as a crystal spike rather than a round gem, with zero extra vertices to manage. The same trick underlies the faceted look in the crystal cluster snippet, though there it's static rather than scroll-driven.
Staggering the reveal so shards bloom in sequence
The naive approach — scaling every shard from the same scroll value — makes the whole cluster pop into existence at once, which looks like a glitch rather than a bloom. Instead each shard stores its own revealAt offset spread evenly across roughly the first 82% of the scrubbed range, plus a revealSpan window over which it grows. Every frame, the shard's local progress is computed as (t - revealAt) / revealSpan, clamped to 0–1, and run through an easeOutBack curve before being applied to mesh.scale. Because each shard reads the *same* global t but maps it through its *own* offset window, scrolling down triggers a cascading bloom and scrolling back up collapses the shards in the exact reverse order — no separate timeline or per-shard tween objects are needed.
Why easeOutBack instead of linear scale
A linearly scaled shard grows at a constant rate and looks mechanical. easeOutBack briefly overshoots past 1.0 before settling, which reads as the shard "popping" into place with a little spring — much closer to how a real crystal facet catching the light suddenly feels present. The overshoot only needs a few lines of cubic math (no easing library required) since the function is evaluated by hand every frame.
Positioning shards on a sphere with quaternions
Each shard's position comes from a random point on a sphere via spherical coordinates (theta, phi), and its orientation is set with mesh.quaternion.setFromUnitVectors(Vector3(0,1,0), dir) so the shard's stretched long axis points radially outward from the cluster's core along the same direction as its position. This is what makes the cluster look like crystals *growing outward* from a shared center rather than a scattered pile of random shapes — the direction used to place the shard is reused to orient it.
Feature-detecting MeshPhysicalMaterial's transmission
Not every three.js build exposes transmission, clearcoat, and thickness on MeshPhysicalMaterial — those properties were added in a later revision. The snippet checks 'transmission' in new THREE.MeshPhysicalMaterial() at runtime and, if present, configures a genuinely glass-like refractive material; otherwise it falls back to a MeshStandardMaterial with low roughness and high metalness that still looks glossy under the point lights, so the demo degrades gracefully rather than throwing on an older CDN pin.
Two colored point lights instead of one
A single light source on faceted geometry tends to leave half the cluster in flat shadow, hiding the very facets the material is meant to show off. A violet keyLight and a cyan rimLight are placed on opposite sides of the cluster, and the key light's position is animated in a slow circle tied to the same scrubbed t, so as the bloom progresses the highlight sweeps across newly revealed shards rather than sitting static. A HemisphereLight underneath provides just enough ambient fill that unlit facets read as dark gem rather than pure black. For a different lighting mood in the same gallery, compare this dual point-light rig to the single directional setup used in the scroll wave terrain snippet.
A live shard counter tied to the same progress value
The HUD's shard count is not a separate state machine — it increments the same loop that drives scale, counting any shard whose clamped local progress exceeds a small threshold. Deriving the HUD from the identical per-frame calculation as the visuals guarantees the number on screen always matches what's rendered, even mid-scroll-scrub.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You do not need to work out the stagger math by hand. Paste this snippet's HTML, CSS, and JS into an AI assistant like Claude and ask it to explain why each shard stores its own revealAt and revealSpan instead of sharing the global scrubbed value directly, or why easeOutBack is computed inline rather than imported. The same assistant can help you extend the effect — ask it to vary shard geometry (try octahedra or custom BufferGeometry facets), add a shimmer pass where opacity briefly flickers as each shard reveals, or drive the bloom's color palette from a CSS custom property so it matches a page theme. It can also help you profile the transmission material's cost and suggest a cheaper fallback for mobile. Treat the code as a starting point to question and reshape, not a finished artifact.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll-triggered crystal bloom" in plain HTML, CSS, and JavaScript using Three.js, GSAP, and GSAP's ScrollTrigger plugin, all loaded from a CDN (no bundler, no build step).
Requirements:
- A pinned section containing a full-size canvas, with a WebGLRenderer and PerspectiveCamera sized to it and updated on window resize including aspect ratio.
- A small seed IcosahedronGeometry mesh at the origin using a refractive-looking material (MeshPhysicalMaterial with transmission/clearcoat if the loaded three.js version supports it, feature-detected at runtime, otherwise a glossy MeshStandardMaterial fallback), tinted a gem color.
- Roughly 20 additional "shard" meshes: elongated icosahedra (non-uniform geometry.scale on one axis) placed at random points on a sphere around the seed using spherical coordinates, each oriented via quaternion so its long axis points outward from the core along its placement direction.
- Each shard stores its own reveal offset and reveal span spread across the scrubbed progress range, so shards bloom in a staggered cascade rather than scaling up simultaneously.
- Register a GSAP tween on a ScrollTrigger targeting the pinned section, with pin: true, start at top top, a numeric scrub, and a multi-hundred-percent end, animating a single plain 0-1 progress value.
- Every animation frame (requestAnimationFrame, independent of the scroll callback), compute each shard's local progress from the global value and its offset/span, run it through an eased curve (e.g. a hand-written easeOutBack), and apply it to mesh.scale, clamping to avoid zero or negative scale.
- Add at least one ambient or hemisphere light plus two colored point lights positioned on opposite sides of the cluster so faceted geometry catches visible highlights, and slowly orbit one light in sync with scroll progress.
- Include a small HUD counting how many shards have crossed a reveal threshold, derived from the same per-frame calculation as the visuals.
- Confirm scrolling back up reverses the bloom in the correct staggered order, since progress is fully scrubbed rather than a one-way timer.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Load all three CDN scriptsAdd three.min.js, gsap.min.js, and ScrollTrigger.min.js from the CDN panel, in that order.
- 2Paste HTML, CSS, and JSA single seed crystal appears in a pinned 3D stage with a shard counter in the corner.
- 3Scroll downThe seed grows first, then roughly twenty gem shards bloom outward from the core in a staggered cascade.
- 4Scroll back upThe cluster collapses shard by shard in reverse order, since every reveal is derived from the scrubbed value.
- 5Retint the gemsEdit the gemColors array to swap amethyst, cyan, emerald, and pink for your own palette.
- 6Retune the bloom pacingChange SHARD_COUNT, revealSpan, or the ScrollTrigger end value (+=420%) for a denser cluster or a slower, longer bloom.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each shard is assigned its own revealAt offset spread across most of the scrubbed range, so the same global t value maps to a different local progress window per shard. Scaling every shard from the identical t value would make the whole cluster pop into existence in one frame, which reads as a glitch; staggering the offsets turns it into a visible cascade that also reverses cleanly when scrolling back up.
The snippet checks for the transmission property at runtime with a feature-detection guard and, if it is missing, falls back to a MeshStandardMaterial with low roughness and high metalness. That still looks glossy and gem-like under the two point lights, so the effect degrades gracefully instead of throwing an error on an older pinned CDN version.
A linear scale-up looks mechanical and constant-speed, which does not read as a crystal snapping into place. easeOutBack briefly overshoots past full scale before settling back, mimicking a small physical spring, and it is cheap enough to compute inline every frame with a short cubic formula rather than pulling in an easing library.
Each shard is a low-poly icosahedron so triangle count stays small, but MeshPhysicalMaterial with transmission is comparatively expensive per-pixel because it samples a transmission render target. On lower-end devices you can drop clearcoat, disable transmission, or merge shards into fewer draw calls if frame rate matters more than refraction fidelity.
Click JSX, Vue, Angular, or Tailwind in the export panel. Create the scene, lights, seed, and shard meshes inside a mount effect against a canvas ref, register the ScrollTrigger tween there, and on cleanup kill the ScrollTrigger instance (or revert a gsap.context) plus call renderer.dispose() so the pinned section and WebGL context do not leak when the component unmounts.