Behind the design

How the facial page was built.

A short, technically honest breakdown of the native techniques behind /facial-acupuncture: one fragment shader, one animation library the site already had, one hand-drawn SVG, and no new dependencies.

React 18Vite 7Tailwind 3GSAP + ScrollTriggerRaw WebGLSVG + SMIL

01

The WebGL silk field

The hero and closing panels are painted by a single fragment shader: raw WebGL, no three.js. A fullscreen triangle runs domain-warped fractal Brownian motion: one fbm field (q) warps a second (r), which warps the final sample, producing the folded-silk look. Champagne filaments are extracted with a smoothstep band around the fold ridges, and rare terracotta embers appear only where two fields agree. It costs one draw call per frame.

  • Device pixel ratio capped at 1.5 to keep retina fill-rate cheap
  • requestAnimationFrame pauses via IntersectionObserver when off-screen
  • prefers-reduced-motion renders exactly one frame, then stops
  • A CSS gradient sits behind the canvas as the no-WebGL fallback

02

GSAP as the choreographer

All motion runs through GSAP with ScrollTrigger, orchestrated inside gsap.matchMedia() so every animation is conditional on both viewport and motion preference. The ritual section is a pinned timeline: 280% of scroll distance scrubbed through four stage crossfades, with a progress hairline scaled in sync. Below 1024px the pin is never created: the same content renders as a stacked editorial column instead.

  • Initial states are set by GSAP, not CSS: prerendered HTML is fully visible without JavaScript
  • A single [data-reveal] convention drives every scroll entrance
  • Stat counters tween a plain object and write into the DOM on update
  • gsap.context() + matchMedia.revert() make React strict-mode cleanup exact

03

The hand-drawn face map

The acupuncture zone map is a single SVG: one continuous cubic-bézier line for the profile, a second translucent stroke for the hair, six interactive point groups. On scroll entry each path measures its own length and animates stroke-dashoffset from that value to zero, the classic draw-on effect, but computed at runtime so the paths can be re-sculpted freely.

  • Points are keyboard-operable: role="button", tabIndex, Enter/Space handlers
  • A 20px-radius transparent hit circle sits behind every 2.5px visible dot
  • An IntersectionObserver gates the cycle so it only runs while the section is on screen; first interaction stops it for good
  • The active zone broadcasts via SMIL <animate>: a pulse with zero JS per frame

04

Design system, extended not replaced

The page inherits the site's tokens: moss, terracotta, cream, Cormorant Garamond, IBM Plex Mono, and earns exactly two new ones: champagne (#C9A87C) and moss-deep (#1F2D25). The luxury register comes from restraint elsewhere: mono microlabels tracked at 0.3em, serif italic pivots inside headlines, numbered sections (01 to 07) that make a long page feel curated rather than long.

  • Type scale peaks at clamp-free Tailwind steps: text-8xl serif against 11px mono
  • Every section label follows the same grammar: number, middle dot, noun phrase
  • The comparison table is a grid, not a <table>, so columns compress gracefully
  • Film grain is the site-wide .noise-overlay, the page adds no new texture layers

05

Conversion architecture

This page exists for Meta ads traffic, so the selling is structural. Price appears in the first viewport, in the sticky rail, and in the closing CTA, never hidden. The sticky rail mounts only after the hero leaves the viewport (IntersectionObserver), full-width on mobile, a floating pill on desktop. Claims are kept inside AHPRA advertising guidelines: the copy says "supports" and "aims to", never "erases" or "guarantees".

  • One primary action, repeated: Book. Every other link is deliberately quieter
  • Objection handling is ordered: price → comparison → FAQ
  • FAQ answers double as schema.org FAQPage structured data
  • The comparison section closes with an explicit non-equivalence disclaimer

06

Performance & delivery

Images are AI-generated at 2752px, converted to WebP, and run through the site's ffmpeg pipeline into five responsive widths served via srcset. The route is lazy-loaded like every page, prerendered to static HTML at build time by the site's SSR pass, and the shader, map, and pinned scroll all initialise after hydration.

  • Hero image: fetchpriority="high" + sizes tuned to its 62vw layout slot
  • Ritual and pricing images lazy-load below the fold
  • The WebGL context requests low-power GPU mode
  • Total new JavaScript: two components, zero new dependencies

See it in motion.

The page reads better than any writeup of it.