Building the Floral Theme, One Petal at a Time
From the maker of Shaqyrtu
The Floral theme looks like a photograph that decided to move. Petals drift down a soft, painterly sky; as you scroll they gather — first into a slowly turning wreath, then into the shape of a heart, then into a bouquet at the block where you reply. None of it is a video or a GIF. There is no frame to loop and no clip to download. Every pixel is drawn on your phone's GPU, from math, sixty times a second.
I want to build it up the way it was actually built: start with an empty canvas, add the smallest thing that renders, and only then add the next. By the end you'll see how a "field of flowers" is really a couple of draw calls and a lot of careful cheating.
Step 0: one canvas, and a decision
Everything lives on a single three.js scene rendered with a plain requestAnimationFrame loop. The camera is a PerspectiveCamera with a 50° field of view, and I place it at exactly the distance where the z = 0 plane fills the viewport:
camera.position.z = height / 2 / Math.tan(fovRad / 2)
That one line buys a lot of comfort: because the flower plane spans exactly the screen height in world units, I can author every position later in plain CSS pixels and it just lands where I expect. The render loop advances a single uTime in seconds and clamps the per-frame delta to 0.05, so when you switch tabs and come back, nothing lurches:
const dt = Math.min((ts - tPrev) / 1000, 0.05)
There is nothing on screen yet. Let's give it a sky.
Step 1: the sky is a noise function
The background is not an image. It is one full-screen triangle with a fragment shader that paints a soft, cloudy wash — the "silk". The shader is fractal value noise (a small fbm, three octaves), domain-warped so the bands curve instead of running straight, and then tinted by four palette colors:
// three octaves of value noise, warped by itself
float n = fbm(uv * 2.0 + warp);
vec3 sky = mix(uC1, uC2, smoothstep(0.2, 0.8, n));
The palette is called Poppy Field: a cyan sky, pink blooms, gold heart accents. Getting the sky to sit under the petals without a muddy seam took one non-obvious ordering fix — paint the cool background first, then composite the warmer petal washes on top. Do it the other way and a warm/cool diagonal fights along the blend boundary. Cool-first, warm-on-top, and the seam disappears.
A few uniforms drive the whole thing — resolution, time, scroll, and three "mood" weights (uM1, uM2, uM3) that let the sky lean warmer or cooler as you move between the names, the calendar, and the reply sections. The sky is never static, but it never demands attention either. That's the job.
Step 2: a petal is a lie told by the fragment shader
Here is the trick the whole theme rests on: there is no flower geometry. A petal is a flat unit quad — four vertices — and its shape is drawn entirely in the fragment shader. Given the quad's local coordinate p, the shader carves a teardrop:
float t = clamp(p.y * 0.5 + 0.5, 0.0, 1.0);
float w = 0.72 * sin(pow(t, 0.85) * 3.14159); // teardrop width along the length
float f = pow(abs(p.x) / max(w, 1e-4), 2.0)
+ pow(abs(t - 0.52) / 0.54, 2.3); // radial "inside/outside" field
float alpha = (1.0 - smoothstep(1.0 - vBlur, 1.0 + vBlur * 0.8, f)) * vAlpha;
f is a smooth field that is small inside the petal shape and grows past 1.0 outside it. The smoothstep turns that boundary into a soft, out-of-focus edge — the vBlur term is what makes far petals read as bokeh rather than hard cut-outs. No texture, no mesh; a teardrop is just an inequality.
The silky sheen along the edge is a rim term. I want to be precise here, because it's easy to over-claim: this is not a dot(normal, viewDir) fresnel — the petal has no normals and there is no view vector. It's a 2D highlight keyed off the same silhouette field f, tinted cyan so the edges catch a cool light against the warm body:
col += uRim * smoothstep(0.55, 1.0, f) * (0.22 + (1.0 - vBlur) * 0.42);
Cheaper than lighting, and for a flat, dreamy petal it reads better too.
Low vBlur = crisp petal; high vBlur = soft bokeh. uRim adds the cyan edge light. No texture, no mesh — just the field f.
Step 3: from one petal to fifty-three, for free
One quad is one petal. To fill the sky I don't create fifty-three objects — that would be fifty-three draw calls and a stuttering phone. Instead the petals are an InstancedBufferGeometry: one quad, drawn many times in a single call, each instance reading its own per-instance attributes.
They're split into three depth bands so the field has real front-to-back air:
| Band | Count | Size | Feel |
|---|---|---|---|
| Far dust | 26 | unit·0.020 | tiny, blurred specks |
| Mid | 22 | unit·0.042 | crisp, the "real" petals |
| Near bokeh | 5 | unit·0.115 | huge, soft, out of focus |
Fifty-three instances, one draw call. Each carries a little bundle of attributes — a seed, a fall speed, a sway amplitude, a spin rate, a base depth, and (we'll get to it) target positions for the formations. The near band is deliberately tiny in count and huge in size: a handful of soft blobs drifting close to the lens is what sells "depth of field" without any actual depth-of-field pass.
Step 4: motion lives in the vertex shader
Now they move — and all of the movement happens in the vertex shader, per instance, on the GPU. Left drifting, a petal:
- falls in a seamless loop, using
fract(seed + uTime * fallSpeed)so it wraps from bottom back to top with no visible reset; - sways side to side and reacts to a
uWindvalue that I feed from your scroll velocity, so flicking the page stirs the field; - tumbles in 3D via three small rotation matrices
rX · rY · rZ, which is what keeps a flat quad from ever looking flat.
Because every petal has its own seed and rates, fifty-three quads never fall in lockstep. It looks organic for the price of a few sin calls.
Step 5: giving the petals somewhere to go
Drifting is pretty, but the emotional arc of an invitation needs intent. So each section of the page has a presence bell — a smooth 0 → 1 → 0 weight that peaks when that section is centered on screen. I compute it from the scrolled position, and I smooth the scroll itself so nothing snaps:
uScroll += (rootTop - uScroll) * 0.06 // critically-damped follow
When a section's bell rises, its petals leave the drift and morph toward a target shape. There are three acts:
- The wreath. Petals slot into a ring — polar targets
(angle, radius, hubY)— and the shader adds a slowuTime · 0.20so the whole wreath turns gently. The radius fits itself to the copy box so the words sit inside the ring. - The heart. This is the one piece of honest parametric math in the theme. The petal targets trace the classic heart curve:
The flowers arrange themselves into that outline. It's the layout that's parametric, not the petals.
hx = 16 * Math.pow(Math.sin(t), 3) hy = 13*Math.cos(t) - 5*Math.cos(2*t) - 2*Math.cos(3*t) - Math.cos(4*t) - The bouquet. At the reply block the petals gather into a turning wheel whose hub sits just below the bottom edge, so you see the top of a bouquet crowning the RSVP. Many drifting things becoming one, exactly where the guest is asked to join.
The morph itself is the "bloom". Each petal eases from its drift position to its target with a smoothstep, offset by a per-petal stagger so the shape assembles one flower after another instead of snapping into place:
float w = clamp(bell - stagger, 0.0, 1.0);
float we = w * w * (3.0 - 2.0 * w); // smoothstep — the "bloom" easing
center = mix(driftPos, formPos, we)
+ perp * sin(we * 3.14159) * 90.0; // arc out on the way in, so it flies rather than slides
Scroll away and the bell falls; the same easing runs in reverse and the flowers dissolve back into drifting petals. Between acts a gentle swirl keeps everything alive so the field never freezes.
Drag scroll. Each act is a smoothstep bell; petals fly in on an arc, staggered so the shape assembles one flower after another. Scrub back and it dissolves.
Step 6: petals in front of and behind your photo
The hero is your photograph, a real DOM element. I wanted petals to orbit it — some passing behind, some crossing in front — which a single canvas can't do, because one canvas is either above or below the photo in the stacking order.
So there are two canvases. A main canvas sits behind the content; a second, transparent overlay canvas sits above it. The orbiting petals (a small instanced ring of eleven) are drawn into both, with a uniform uZSide flipped -1 for the back half and +1 for the front half. The back half renders on the lower canvas, the front half on the upper one, and your photo is sandwiched between them. The petals appear to circle the picture. It's the only place the theme spends a second WebGL context, and it's worth it for that one effect.
Watch a petal at the top edge: it slides behind the photo, comes around the bottom, and crosses back in front. In the real theme those two halves live on two stacked canvases with the photo between them.
Step 7: touch, and staying kind
Tap the sky and the nearby petals scatter — a ripple, not a new flower. A pointer handler records where and when you touched (uTouchPos, uTouchTime) and the vertex shader pushes petals away from that point, fading with distance and age:
float push = exp(-dist / 220.0) * exp(-age * 2.4) * uMotion;
center += normalize(d) * push * 46.0;
Two more manners matter. If you have reduce motion turned on, a single uniform uMotion = 0 freezes every time-driven term — the petals and sky simply hold, no special-case code. And after you stop scrolling near a formation, a soft magnetic snap nudges the last few pixels so the wreath or heart lands clean instead of half-formed.
Tap or click the petals — the ripple pushes them out and settles back.
Step 8: the bug that only happened in the wild
Everything above worked perfectly in the editor and broke on the first real invitation. On the public /i/:slug page — which is server-rendered and then hydrated — the canvas would silently never initialize.
The cause is a timing quirk of SSR hydration: inside onMounted, the template ref to the canvas was still null. The DOM existed, but Vue hadn't finished binding the refs at the instant onMounted fired. Handing a null canvas to three.js gets you nothing, and it only reproduced under hydration — a pure client mount never showed it.
The component is a .client.vue wrapped in <ClientOnly> so WebGL never runs during server render, and the init waits until the refs are actually bound before touching the GPU:
onMounted(async () => {
// onMounted can fire a tick before template refs are bound under SSR hydration.
for (let i = 0; i < 20 && (!canvasRef.value || !host.value); i++) {
await nextTick()
}
if (!canvasRef.value || !host.value) return // give up gracefully, never crash
rootEl = host.value.closest('.floral-scroll')
if (!rootEl) return
renderer = new THREE.WebGLRenderer({
canvas: canvasRef.value,
antialias: true,
alpha: true,
powerPreference: 'low-power'
})
// …start the render loop
})
It's a small loop, but it encodes the real lesson: SSR + WebGL is a timing problem, not a rendering problem. The GPU code was fine the whole time; the canvas simply wasn't there yet. Guarding the init against a not-yet-bound ref — rather than trusting onMounted — is the difference between a demo that works on your laptop and a theme thousands of guests open on their phones.
Why bother
A photograph of a flower is a picture of something that already happened. A flower that assembles itself while you scroll is happening now, on your device, and never exactly the same way twice. That present-tense, unrepeatable quality is the whole bet of the Floral theme — and it's why it's fifty-three quads and two shaders instead of a video file. Start from a blank canvas, add one honest cheat at a time, and you end up with something that feels alive.