← Back to Still Water Build Notes

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.

Sage#8fa07f
Sage Deep#47573d
Sand#e9dcc4
Clay#c79878
Clay Deep#8a5230
Sky#aec3c8
Cream#faf6ec
Ink#2f2b23

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.

/* the pairing */ --font-display: "Fraunces", "Iowan Old Style", serif; --font-body: "Work Sans", -apple-system, sans-serif;

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-motion is checked once and freezes the drift entirely if set; the loop also pauses on visibilitychange so a backgrounded tab doesn't burn CPU.
// the core per-frame blob math const x = (b.baseX + Math.sin(t * b.speedX + b.phaseX) * 0.09) * width; const y = (b.baseY + Math.cos(t * b.speedY + b.phaseY) * 0.11) * height; const breathe = 1 + Math.sin(t * b.breathSpeed + b.breathPhase) * 0.16;

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 ScrollTrigger toggleClass.
  • Fluid type everywhere — no fixed px font sizes on any heading; all clamp()-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:

  1. Push the still-water/ folder to a Git repository (or drag-and-drop it directly in the Netlify dashboard).
  2. 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.
  3. Netlify serves index.html at the root and guide/index.html at /guide/ automatically, since both are real folders with real index.html files — no redirect rules needed.
  4. 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:

Pass 1

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.

Pass 2

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.

Pass 3

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.