The guide

How this site breathes.

The math behind the blobs, the clock behind the breath, and the discipline behind the palette — explained the SOMA way: slowly, and only what matters.

01 · The concept

Nothing here has a corner.

SOMA is a fictional breathwork and rest studio, and its entire visual language is organic motion. The school it draws from is soft organic modernism — the world of spa brands, ceramics studios, and wellness identities where every shape looks hand-thrown rather than die-cut. The signature material is the morphing blob: a living silhouette that never repeats and never sits still.

The design rule was absolute: no hard corners, anywhere. Pills for buttons and table rows, big soft radii for cards, uneven organic radii for accents, and generated blob paths for everything decorative. One easing curve, never faster than 600ms, so the whole page moves like it just woke up from a good nap.

02 · The blob math

A circle with opinions.

Every blob starts as n points spaced evenly around a circle. Each point owns two private numbers: a random noise phase and a slow speed. Each frame, the point's radius is the base radius nudged by a sine wave of its own phase — a radial function with per-point noise:

// n points; each carries its own phase & speed
const a = i / n * Math.PI * 2;
const r = base * (1 + amp * Math.sin(phase[i] + t * speed[i]));
const x = 50 + r * Math.cos(a);
const y = 50 + r * Math.sin(a);

Because every point drifts at a slightly different speed (0.25–0.6 radians per second here), the silhouette never repeats — but because sin is continuous and the speeds are slow, it also never jitters. The motion is smooth by construction; there is nothing to interpolate.

To turn ten points into a curve with no visible vertices, the path passes a quadratic bezier through each point, anchored at the midpoints between neighbours. Midpoint smoothing guarantees the curve is tangent-continuous all the way around:

// quadratic beziers through points, anchored at midpoints
function smoothPath(xs, ys){
  const n = xs.length;
  let d = /* M at midpoint of last→first */;
  for (let i = 0; i < n; i++){
    const j = (i + 1) % n;
    const mx = (xs[i] + xs[j]) / 2, my = (ys[i] + ys[j]) / 2;
    d += ` Q${xs[i]} ${ys[i]} ${mx} ${my}`;
  }
  return d + " Z";
}

The whole engine is one requestAnimationFrame loop rewriting one d attribute per blob. Four blobs, ten points each — the browser barely notices. No canvas, no WebGL, no library.

03 · Syncing to breath

One clock, two outputs.

The hero blob breathes a real pattern: four seconds in, four held, six out. A single pure function maps elapsed time to a phase label, a countdown, and a swell value between 0 and 1:

const IN = 4, HOLD = 4, OUT = 6, CYCLE = 14;
const ease = x => 0.5 - 0.5 * Math.cos(Math.PI * x); // half-cosine

function breathState(sec){
  const c = sec % CYCLE;
  if (c < IN)      return { label: "Inhale", value: ease(c / IN) };
  if (c < IN+HOLD) return { label: "Hold",   value: 1 };
  return { label: "Exhale", value: 1 - ease((c - IN - HOLD) / OUT) };
}

The swell value scales the blob's base radius; the label drives the caption in the blob's centre. Because both come from the same function on the same clock, the word and the shape can never drift apart — the caption doesn't animate "alongside" the blob, they are two views of one state.

The half-cosine ease matters more than it looks: a linear swell reads as mechanical, but the cosine starts and ends each phase with zero velocity — exactly how lungs behave. The "Breathe with it" toggle simply dims the rest of the page and enlarges the blob; the clock never changes. Under prefers-reduced-motion, the blobs freeze at a mid-breath shape and the exercise falls back to a text-only timer — the feature survives, only the motion leaves.

04 · Palette discipline

Five colors, no exceptions.

Sand
#ede5d8
Clay
#d9c3ab
Sage
#a8b8a0
Terracotta
#c07856
Moss
#3a4438

Warm neutrals do the structural work: sand for the page, cream for cards, clay for the breathing blob. Sage and terracotta are spent like money — accents, avatars, one pull-quote. Moss is the only text color, and it moonlights as the dark band for testimonials, where sand text flips the scheme instead of introducing a new one.

Each color also has a job in the contrast budget. Moss on sand clears AA with room to spare, so all body text lives there; terracotta and sage only ever carry large display type or pure decoration. Discipline isn't just picking five values — it's refusing to let the sixth one in when a shadow gets hard to tune.

Type follows the same restraint: Fraunces with its SOFT variable axis pushed to 100 — a serif with literally rounded corners — over Karla, a gentle humanist sans. Two families, pushed hard, nothing else.

05 · How it was made

By hand, in one sitting.

This site was built by Claude (Fable 5) writing vanilla HTML, CSS, and JavaScript by hand — no frameworks, no build step, no image files. Every shape on the page is generated: the blobs are runtime SVG paths, the grain is an inline turbulence filter, the favicon is a data URI.

It's one of twenty-five wildly different demo sites in the Fable Showcase, each proving out a distinct school of web design. See the rest at fable-25-dhb.pages.dev.

06 · Steal this

Take it with you.

  1. Drive SVG paths from rAF. Rewriting one d attribute per frame is cheap and gives you organic motion no CSS keyframe can fake. Ten points and a sine wave beat a 4MB Lottie file.
  2. One clock, many outputs. When text and animation must stay in sync, derive both from the same pure function of elapsed time. If they can't drift, you never have to debug the drift.
  3. One easing curve, everywhere. Pick a single cubic-bezier and a floor duration (here: 600ms+) and use them for every transition. Cohesion comes from repetition, not variety.
  4. Uneven radii read as organic. border-radius: 58% 42% 55% 45% / 52% 60% 40% 48% turns any square element into a hand-thrown one. Use it for bullets, badges, and counters.
  5. Reduced motion means freeze, not delete. Stop the animation at its most pleasant frame and keep the feature working another way — here, a text-only breath timer. Respecting the preference shouldn't cost the experience.