How this site was designed and built.
A specific, honest account of the concept, the art direction behind every image, the animation techniques behind the ambient background, and how the finished static site would ship.
01 — Concept
Still Water is a small yoga and wellness retreat brand built around one idea: most "wellness" branding is loud — lotus clip-art, upbeat gradients, a script font pretending to be a signature. This site does the opposite. It's slow, quiet, and a little expensive-feeling, the way a real boutique retreat's site would be if it were designed by someone who actually understood restraint.
The brief called for pastel-plus-earth tones, a flowing water/blob ambient background, a serif that reads as genuinely elegant (not a wellness-brand script cliché), and motion that feels like breathing — slow easing, generous pauses. Every decision below traces back to one of those four constraints.
02 — Palette
Pulled from the brief's "pastel + earth tones" direction, then tuned against WCAG contrast math rather than eyeballed — pastel-on-pastel is exactly the kind of thing that looks fine in a mockup and fails in daylight on a phone.
The rule I held to: every text/background pairing had to clear 4.5:1 contrast, computed with the actual WCAG relative-luminance formula (not a visual guess) — ink-faint, clay-deep, and sage-deep were all darkened one pass from their first values once I ran the numbers against every background they touch (cream, sand-light, and sage-pale).
03 — Typography
Headlines run in Fraunces — a soft, high-contrast display serif with a wide optical-size axis, so it can flex from italic pull-quotes down to a 12px eyebrow label without ever looking like a "yoga brand" script font. Body copy runs in Work Sans at a light 300 weight for the long-form paragraphs — airy, humanist, gets out of the way.
Type is fluid throughout via clamp() rather than fixed breakpoint sizes — the hero title alone spans clamp(52px, 8.4vw, 118px), so it scales continuously between a 375px phone and a 1440px desktop instead of jumping at arbitrary breakpoints.
04 — The ambient blob background
This was the one required "non-generic" technique, so it got the most iteration. I built it as a canvas layer (js/blob.js) rather than an SVG turbulence filter, for a specific reason: feTurbulence/feDisplacementMap is gorgeous for texture but hard to art-direct — you get noise, not composition. A retreat site needed shapes that read as deliberate pools of light, not static.
How it works
- Four soft radial-gradient "blobs," one per palette color (sage, clay, sky, sand), each drawn as a transparent-edged circle with
ctx.createRadialGradient. - Each blob's position is driven by two independent sine/cosine waves with different periods and phase offsets per axis — this is the trick that keeps the motion from ever repeating in an obviously loop-y way (a single sine wave reads as a bounce; two offset waves per blob reads as drift).
- A separate, slower sine term drives each blob's radius (a "breath" — full cycle roughly 50 seconds), so the whole field very slowly inhales and exhales independent of the drift.
- The canvas context applies
ctx.filter = blur(...)scaled to viewport size before every fill, which is what turns four flat circles into diffuse, water-like light rather than visible shapes. prefers-reduced-motionis checked once and freezes the drift entirely if set; the loop also pauses onvisibilitychangeso a backgrounded tab doesn't burn CPU.
A second, much subtler layer sits on top: a fixed SVG feTurbulence grain texture at 3.5% opacity with mix-blend-mode: multiply (.grain in the CSS) — this is the one place an SVG filter is used, purely to kill the "flat digital gradient" look and give the whole page a faint paper/film grain.
05 — Breathing motion (GSAP)
The brief's line — "motion should feel like breathing" — became a literal design constraint on every GSAP timeline in js/main.js: no ease steeper than power2.out, no duration under ~1.1s for anything that isn't a hover state, and deliberate pauses between staggered reveals (0.16s stagger, not the 0.05s "everything pops at once" default).
The hero's breathing orb is the most literal expression: a GSAP timeline that scales a radial-gradient circle up over 4.2s, holds, exhales over 5.6s, and repeats — an asymmetric in/hold/out pattern (roughly the classic 4-2-6 extended-exhale breathing count used in real breathwork practice), with a text label that updates on each timeline phase via .call().
Scroll-triggered reveals use ScrollTrigger with generous trigger offsets (top 88%, well before the element is centered) so content arrives ahead of the user's scroll rather than snapping in late, and the gallery images get a slow, scrubbed parallax (scrub: 1.2) rather than a discrete on/off animation.
06 — Image generation & art direction
All nine images were generated through Higgsfield — seven with Nano Banana Pro for the landscape/architecture/still-life editorial shots, two with Higgsfield Soul 2.0 for the instructor portraits (better at believable human faces at this scale). Each one — the dawn-pavilion hero, the soaking pool, the footpath, the architecture shot, the water-ripple macro, the dusk-water CTA, the morning table, and the two guide portraits — was generated from a carefully art-directed brief specifying the light quality, framing, palette, composition, and mood, with the brand's earth-tone palette named explicitly so the generations stayed on-brand instead of drifting toward generic stock-photo saturation. Each was iterated until it matched the brand (the morning-table shot was generated during Pass 3 to replace a repeated hero image in the gallery grid — see the iteration log below). The full prompt library is proprietary and available to clients on request.
Every generated PNG was re-encoded to JPEG with sips (quality ~78–82, longest edge capped at 1600–2200px) before landing in assets/ — the raw generations came back at 7–11MB each; the compressed set totals well under 6MB for all nine images combined.
07 — Layout & craft details
- Asymmetric gallery grid — a 6-column CSS grid with images spanning 2–4 columns and one item offset with
align-self, so "The Setting" doesn't read as a generic evenly-cropped photo grid. - Numbered program rows instead of three matching cards — a horizontal list with an index, a name, a description, and a circular arrow that rotates 45° and inverts color on hover. Reads as an editorial index, not a pricing-table template.
- Safe, real contrast — every color pairing in the palette section above was checked against the WCAG formula programmatically rather than by eye (see 02 — Palette).
- Sticky, dissolving header — transparent over the hero, and only gains a blurred cream background (
backdrop-filter: blur(14px)) once the user scrolls past 80px, via a single ScrollTriggertoggleClass. - Fluid type everywhere — no fixed
pxfont sizes on any heading; allclamp()-based.
08 — Deployment
This is a static site with zero build step — plain HTML, CSS, and vanilla JS, with GSAP and Google Fonts loaded from CDN. That means deployment is close to the simplest case Netlify supports:
- Push the
still-water/folder to a Git repository (or drag-and-drop it directly in the Netlify dashboard). - In Netlify, create a new site from that repo/folder with no build command and a publish directory of the project root (
still-water/) — there's nothing to compile. - Netlify serves
index.htmlat the root andguide/index.htmlat/guide/automatically, since both are real folders with realindex.htmlfiles — no redirect rules needed. - Attach a custom domain and Netlify's free TLS cert; done.
No environment variables, no server, no database — the only "backend" the site references is a mailto: link on the booking CTA.
09 — Iteration log
After the first complete build, I ran three explicit fresh-eyes passes over the whole site. One line each, logged honestly:
Ran every text/background color pairing through the actual WCAG contrast formula instead of eyeballing it — found ink-faint (4.26:1), clay-deep (3.69:1), and sage-deep (4.19:1 on the sage-pale philosophy background) all fell short of the 4.5:1 AA minimum. Darkened all three tokens until every pairing actually used in real copy clears AA on every background it touches.
Caught a broken relative link — the main nav and footer credit pointed to ../guide/index.html, which resolves one level above the site root and 404s. Fixed to guide/index.html. Also pulled "Build Notes" out of the primary nav and mobile drawer: a real retreat site wouldn't advertise its own build documentation in the main menu, so it now lives only as the (expected) footer credit link.
Found the hero's entrance motion was quietly broken: several hero elements carried both the generic .reveal class (for scroll-triggered fades elsewhere on the page) and an inline transform:none meant to reset it — which zeroed out the intended slide-up before GSAP's dedicated hero timeline ever ran, so the "breathing" entrance was reduced to a plain fade. Split hero content onto its own .hero-anim class so the two animation systems never fight over the same node. Also fixed the program list silently deleting its description text below 980px (display:none) — it now reflows under the title instead of disappearing — and replaced a repeated hero photo in "The Setting" gallery with a newly generated, distinct image so the same shot doesn't appear twice on one page. Last catch: the carefully art-directed dawn-lake hero image was never actually placed anywhere on the page — it now sits as a soft, vignette-masked disc behind the breathing rings, tying the literal "breathing visual" back to the retreat's real scenery instead of leaving it purely abstract.