MembraneHero
Fluid-mesh editorial hero surface.
Live demo
01A new kind of canvas
Hand-crafted Svelte 5 primitives. Zero runtime cost. Every animation respects prefers-reduced-motion.
Ship a story, not a stack
Editorial layouts for product launches, season campaigns, and announcement pages.
Quiet light, deep focus
A composition palette for documentation sites, technical journals, and quiet brand pages.
Implementation
02<script>
import MembraneHero from '$lib/components/MembraneHero/MembraneHero.svelte';
</script>
β
<MembraneHero
palette="aurora"
eyebrow="Now in beta"
headline="A new kind of canvas"
subhead="Hand-crafted Svelte 5 primitives. Zero runtime cost."
primaryCta="Start building"
secondaryCta="See the docs"
/>MembraneHero stacks a CSS conic-gradient base under an inline SVG <feTurbulence> + <feDisplacementMap> filter. SMIL animates the turbulence baseFrequency so the surface ripples like a fluid film. A Lissajous focal dot, per-glyph headline deal-in, and three palette presets layer on top. Reduced motion freezes the turbulence, drops the dot loop, and disables the deal-in.
Logic explainer
03What Does It Do? (Plain English)
MembraneHero is a full-bleed editorial hero section: an enormous coloured-gradient surface that ripples like a fluid film, with a single tiny "focal dot" wandering across it, and an editorial layout (eyebrow tag, big headline, subhead, two CTA buttons) sitting on top. The headline deals in glyph-by-glyph on mount, the way a typesetter might fan a deck of cards.
Think of it as a still photograph of a soap-bubble's surface β except the surface is breathing. The membrane is a static CSS gradient, but an SVG turbulence filter is constantly sloshing it around, and a roaming dot gives the eye somewhere to track without ever quite settling.
How It Works (Pseudo-Code)
state (MembraneHero):
palette = 'aurora' | 'sunset' | 'polar'
headline text = "A new kind of canvas"
headlineWords = splitWords(headline)
= [{kind:'word', chars:[...]},
{kind:'space', chars:[' ']}, ...]
derive at render:
for each headlineWord, for each char in word:
glyphIndex = running total across all words
render <span class="mh-glyph"
style="--mh-glyph-delay: glyphIndex * 0.024s">
CSS @keyframes mh-glyph-in: opacity 0β1, translateY 0.4emβ0
=> glyphs deal in left-to-right at 24ms apart
state (MembraneSurface):
reduced = isReducedMotion() // probed onMount
dotX, dotY = 0 // current Lissajous position
raf = null
mountedAt = performance.now()
rAF loop tick(now):
if reduced: stop loop
t = (now - mountedAt) / 1000
{x, y} = lissajous(t * 0.18, 3, 2, 0.32, 0.22, Ο/4)
dotX, dotY = x, y // both in [-1, 1]
raf = requestAnimationFrame(tick)
matchMedia listener:
on prefers-reduced-motion change β reduced = e.matches
render:
inline SVG <filter id="mh-displace">
<feTurbulence baseFrequency="0.014" octaves="2" seed="7">
if !reduced:
<animate attributeName="baseFrequency"
values="0.012; 0.024; 0.012"
dur="14s" indefinite />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="38" />
div .mh-mesh with conic+radial gradient
style="filter: url(#mh-displace)"
div .mh-dot positioned at
translate3d(50vw + dotX*38vw, 50vh + dotY*30vh, 0)The membrane's "breathing" is SMIL-driven β the <animate> element inside the SVG filter is what cycles baseFrequency. The dot's drift is the only JS frame loop in the component, and it stops the moment prefers-reduced-motion flips on.
The Core Concept: SVG Displacement + Lissajous Drift
Two distinct techniques layer to produce the visual.
1. feTurbulence + feDisplacementMap = warped membrane
The SVG filter is the visual workhorse:
<filter id="mh-displace">
<feTurbulence type="fractalNoise" baseFrequency="0.014"
numOctaves="2" seed="7">
<animate attributeName="baseFrequency"
values="0.012; 0.024; 0.012"
dur="14s" repeatCount="indefinite" />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="38" />
</filter>feTurbulence generates a Perlin-noise field β a smooth, organic, randomly-shaped image where nearby pixels have similar values. Crucially, seed="7" makes that noise field deterministic, so server and client render identical first frames.
feDisplacementMap then takes the source graphic (our gradient mesh) and uses the noise field as instructions: red channel of noise pushes pixels left/right, green channel pushes up/down, scaled by 38. Pixels in calm parts of the noise barely move; pixels in noisy parts get sloshed by up to ~38 px.
The clever bit is the <animate> on baseFrequency. The base frequency controls the scale of the noise β low values make giant blobs of warp, high values make fine-grained crinkle. Cycling 0.012 β 0.024 β 0.012 over 14 s makes the membrane breathe: it expands into broad, lazy ripples then contracts into tight ones.
2. Lissajous curve = wandering focal dot
The focal dot needs a path that:
- never repeats too quickly (so the eye doesn't predict where it goes next)
- stays inside the visible region (so it doesn't disappear)
- looks organic, not robotic
A Lissajous curve β two perpendicular sine waves at different frequencies β does all three:
lissajous(t, a, b, A, B, phase) = {
x: A * sin(a * t + phase),
y: B * sin(b * t)
}
// MembraneHero calls with t * 0.18, a=3, b=2, A=0.32, B=0.22, phase=Ο/4When a/b is a rational ratio (like 3/2), the curve eventually closes into a knotted figure. Choose a/b = 3/2 and you get a clean trefoil pattern β the dot traces an ever-so-slightly different path each cycle because of the phase offset, but always within [-A, A] Γ [-B, B].
The output is in [-1, 1] units; the component scales to viewport: translate3d(50vw + dotX*38vw, 50vh + dotY*30vh, 0). So the dot wanders within Β±38 % of half the viewport width and Β±30 % of half the viewport height β comfortably inside the visible area.
3. Per-glyph deal-in for the headline
The headline is split into words and individual glyphs. Each glyph gets --mh-glyph-delay: {globalIndex * 0.024}s, and a single CSS keyframe handles the rest:
.mh-glyph {
opacity: 0;
transform: translateY(0.4em);
animation: mh-glyph-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--mh-glyph-delay);
}
@keyframes mh-glyph-in {
to { opacity: 1; transform: translateY(0); }
}splitWords keeps each word's glyphs inside an inline-block <span class="mh-word"> with white-space: nowrap, so line-breaks can only happen at the explicit <span class="mh-space"> between words β never mid-letter.
CSS Animation Strategy
Three independent animation systems, none of which fight each other:
- SMIL
<animate>drives the membrane'sbaseFrequency. SMIL runs in the SVG renderer, completely outside the CSS animation pipeline β norequestAnimationFrame, no main-thread cost beyond the filter recomputation. - CSS
@keyframesdrive the eyebrow fade-down, subhead fade-up, CTA fade-up, dot pulse, and per-glyphmh-glyph-in. All of them areforwards-fill so the layout settles in its final state and never re-runs. - JS rAF loop drives only
dotX, dotYfor the Lissajous translate3d. This is the only thing the main thread does per frame, and it's a single sin/cos calculation.
prefers-reduced-motion is honoured three different ways:
<animate>element is omitted entirely whenreduced === trueβ the SMIL animation never starts.- The rAF tick exits at the top β
if (reduced) { raf = null; return; }. - CSS
@media (prefers-reduced-motion: reduce)resets the deal-in glyphs and pulses to their final states.
Performance
- One rAF loop per instance, doing one
lissajous()call (six sin/cos ops) and writing two state variables. ~0.05 ms/frame. - One SVG filter chain per instance. The filter is computed by the browser's SVG renderer in C++, not from JavaScript. On modern GPUs a 2-octave fractal-noise displacement on a single full-bleed surface is a few ms per frame; on lower-end GPUs reduce to one octave by editing the component.
- Glyph deal-in is one-shot β
forwardsfill plus zeroiteration-countoverrides means each<span>stops animating after 0.6 s. - Membrane is drawn once per turbulence frame, not per CSS frame β SMIL is the rate limiter, not the screen refresh. At 60 Hz with a 14 s SMIL cycle the filter recomputes a few hundred times per cycle, which is well within budget.
- Backdrop-filter on the eyebrow pill (
backdrop-filter: blur(8px)) is the most expensive single CSS rule. If you support older Safari, consider gating it behind@supports.
State Flow Diagram
[SSR / initial render]
β static SVG filter, frozen baseFrequency=0.014
β glyph spans rendered with opacity 0
βΌ
[client mount]
β MembraneHero: per-glyph deal-in fires once (CSS, forwards)
β MembraneSurface: onMount() probes reduced-motion
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β reduced === false β
β β’ SMIL <animate> cycles baseFrequency 14s β
β β’ rAF loop ticks dotX/dotY each frame β
β β’ mh-dot-pulse keyframe runs forever β
ββββββββββββββ¬ββββββββββββββββββββββββββββββββββ
β matchMedia change β reduced flips
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β reduced === true β
β β’ <animate> already absent (no-op) β
β β’ rAF tick exits, raf = null β
β β’ dot frozen at last position β
β β’ CSS @media disables pulse β
ββββββββββββββββββββββββββββββββββββββββββββββββ
[unmount]
β cancelAnimationFrame(raf)
β matchMedia listener removed
βΌ
[destroyed]Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
palette |
'aurora' | 'sunset' | 'polar' |
'aurora' |
Three-stop palette preset (membrane gradient + dot accent). |
eyebrow |
string |
'Now in beta' |
Pill badge above the headline. Also used as aria-label on the section. |
headline |
string |
'A new kind of canvas' |
Display H1 β split into glyphs for staggered deal-in. |
subhead |
string |
'Hand-crafted primitives, zero runtime cost.' |
Single-line lead under the headline. |
primaryCta |
string |
'Start building' |
Primary button label. |
secondaryCta |
string |
'See the docs' |
Secondary button label. |
primaryHref |
string |
'#' |
href on the primary <a>. |
secondaryHref |
string |
'#' |
href on the secondary <a>. |
showDot |
boolean |
true |
Render the Lissajous focal dot. |
class |
string |
'' |
Extra classes on the root <section>. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Headline > ~60 characters | Per-glyph deal-in remains correct but visually janky on long strings; consider trimming or shortening. |
| Headline contains emoji or surrogate pairs | Array.from(text) (used in splitWords) iterates by code point, so emoji stay intact in their own <span>. |
prefers-reduced-motion: reduce set on mount |
SMIL <animate> never inserted; rAF loop never starts; deal-in CSS resets to final state via @media. The hero looks like a static still. |
prefers-reduced-motion flips at runtime |
matchMedia change handler updates reduced; rAF loop exits on its next tick; static frame remains. |
Multiple MembraneHero instances on one page |
Each gets a unique filterId via the uid prop on MembraneSurface β no SVG filter ID collisions. |
| Component unmounted mid-rAF | onMount return cleans up: cancelAnimationFrame(raf) + mq.removeEventListener. |
| Browser without SVG filters (IE11) | Membrane renders as a flat conic+radial gradient, undisplaced. Deal-in still works (CSS only). |
| Hi-DPI display | SVG filter is resolution-independent; the membrane crisps up automatically. The dot is width: 18px so it stays visually consistent. |
| Component scrolled offscreen | rAF loop continues to tick (browser still calls rAF). Cost is small (~0.05 ms/frame); if you embed many MembraneHeroes on one page, gate them behind an IntersectionObserver in your wrapper. |
| Section narrower than ~600 px | clamp(2.6rem, 8vw, 5.8rem) on the headline scales it down; the layout reflows because all CTAs use flex-wrap: wrap. |
headline empty |
splitWords returns []; the H1 renders empty (only the screen-reader span). Deal-in is a no-op. |
Dependencies
- Svelte 5.x β
$props,$state,$derived, snippets ({#snippet glyphSpan}). The component leans on snippets to keep the per-glyph render readable. - Subcomponents β
MembraneSurface.svelte(the warped layer + dot) andtypes.ts(palette resolver, Lissajous helper). Both are private to theMembraneHero/directory. - Zero external runtime dependencies β no GSAP, no images, no font CDN, no animation library. Inline SVG + CSS keyframes + a single rAF loop.
File Structure
src/lib/components/MembraneHero/MembraneHero.svelte # editorial layout + headline deal-in
src/lib/components/MembraneHero/MembraneSurface.svelte # SVG filter + Lissajous focal dot
src/lib/components/MembraneHero/types.ts # MembranePalette, lissajous, helpers
src/lib/components/MembraneHero.md # this file (rendered inside ComponentPageShell)
src/lib/components/MembraneHero.test.ts # vitest unit tests
src/routes/membrane-hero/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
palette | 'aurora' | 'sunset' | 'polar' | 'aurora' | Named gradient + accent preset. |
eyebrow | string | 'Now in beta' | Small uppercased pill above the headline. |
headline | string | 'A new kind of canvas' | Hero headline, animated per-glyph. |
subhead | string | 'Hand-crafted primitives, zero runtime cost.' | Supporting copy under the headline. |
primaryCta / secondaryCta | string | β | Anchor labels for the two CTAs. |
primaryHref / secondaryHref | string | '#' | Anchor targets. |
showDot | boolean | true | Show or hide the Lissajous focal dot. |