Build Guide — How this site was made
Rendering skincare
with no camera in the room
Nkuto Beauty is the collection's color-system showpiece: four product lines, four palettes, one page that changes hue as you move through it. Every jar, tube, dropper and pump is CSS and SVG — nothing photographed, nothing rasterized. This page explains how, generously enough that you could rebuild it.
Concept & creative direction
Nkuto is the Twi word for shea butter — and the brief's whole thesis in one word: something warm, hand-passed, sensory. The brand needed to feel product-cinematic without a single product photograph, so the creative direction inverted the usual beauty-site order of operations:
- Color owns the structure, not the grid. Each of the four lines gets one color from the brief's palette — butter, cocoa, blush, botanical — and a full-height chapter of its own. The page background itself becomes the product photography stand-in.
- Products are typographic objects, not icons. A jar, a tube, a dropper bottle and a pump bottle — each built from layered CSS gradients so they read as dimensional, glossy, sitting under real light, the way a hero product shot would.
- Milk as the neutral. Between the four saturated chapters, the hero, ingredients, ritual and stockists sections all rest on milk
#FFF9EF— a warm off-white rather than clinical white, so the "quiet" sections still feel like skincare, not a spreadsheet.
The environment constraint — no photography, no raster images anywhere in the collection — became the brand's actual manufacturing story: a beauty line that describes its products in the same material it's built from, gradients and line.
Toolchain
Designed and engineered by Fable 5 (Anthropic's model) as designer-engineer, directed by Hannah Kwakye — art direction, palette discipline and final cut are hers. The stack stays deliberately small:
- Hand-authored static HTML, CSS and JavaScript. No framework, no build step, no bundler.
- Zero libraries. The crossfade engine, the reveals, the blob drift and the cursor ripple are IntersectionObserver,
requestAnimationFrameand CSS gradients/animation — nothing imported. - Two self-hosted font files: DM Serif Display (roman + italic cuts) for headlines and product labels, and a variable Hanken Grotesk (400–800) for everything else — three woff2 files total, preloaded,
font-display: swap. - First-view transfer sits well under the collection's 250 KB budget — most of it is the two display fonts; every product, blob and ribbon on the page is markup and CSS, not bytes of image data.
Building the products in pure CSS
Every product uses the same recipe: a rounded box, a cylindrical shading gradient that runs light→dark→light→dark across it (mimicking how a round object catches light from one side), a radial gloss highlight layered on top for the glass/plastic sheen, and a drop-shadow filter to ground it on the page:
.jar-body {
background: linear-gradient(100deg,
var(--butter-d) 0%, #EFB94A 14%, #FBE28C 30%,
#FFF3CE 44%, #FBE28C 58%, #EFB94A 76%, var(--butter-d) 100%);
box-shadow: inset 0 3px 4px rgba(255,255,255,.4),
inset 0 -30px 40px -20px rgba(74,44,26,.25);
}
.jar-body::before { /* the gloss */
background: radial-gradient(ellipse at 30% 25%,
rgba(255,255,255,.85), rgba(255,255,255,0) 65%);
transform: rotate(-10deg);
}
The seven-stop linear gradient is the whole trick: a flat rectangle reads as a cylinder because the eye recognizes that light→dark alternation as a rolled surface, the same way a photographer would light a bottle from one side with a soft fill from the other. Four products vary the recipe:
- Jar (Shea Rich) — a wide body, a ribbed lid (a repeating-linear-gradient strip standing in for pressed grooves), and a frosted label sitting on top of the gloss rather than blocking it.
- Tube (Cocoa Repair) — a light cream body against a dark cocoa chapter, so the product itself becomes the "spotlight." The crimped seal at the top is one
clip-path: polygon()trapezoid with foil-stripe repeating-gradient over it. - Dropper (Baobab Light) — the only product with implied transparency: the bottle is a translucent white gradient over the chapter's blush background, and a separate
.dropper-liquiddiv sits inside it with its own gradient and highlight, so the "liquid level" reads as glass over fluid, not one flat fill. - Pump (Hibiscus Glow) — a nozzle rotated off the head via
transform-origin: left center, so the same box-gradient recipe gets an asymmetric silhouette without a single extra shape system.
Nothing here is a raster asset, a canvas render or an SVG bitmap filter pretending to be a photo — it's the same seven-line gradient recipe, retuned per line, which is what keeps four very different-looking products feeling like one coherent product family.
The page-tint crossfade engine
The brief's central ask — "the page background tint morphs per chapter" — needed to feel continuous, not like four flat-colored <section>s switching at their edges. The accessible baseline is exactly that (each section carries its own solid, AA-checked background/text pairing, so nothing ever breaks without JavaScript). On top of that baseline, a small engine repaints a single fixed layer behind the whole page in real time:
function colorAt(y) {
const s = stopContaining(y); // which section is y inside?
if (nearStartBoundary(y, s)) return mix(prevColor, s.color, t);
if (nearEndBoundary(y, s)) return mix(s.color, nextColor, t);
return s.color; // pure, for most of the dwell time
}
The blend only happens in a 130px window either side of a section boundary — a tall, full-height chapter therefore reads as its own pure color for the vast majority of the time you're inside it, and only cross-fades where one chapter's story is handing off to the next. scrollY + innerHeight / 2 (the viewport's own center) is the sampling point, so the transition lands exactly when the incoming chapter's content is arriving on screen, not before. Each real tinted section becomes background: transparent via a single html.wash-active class, letting the fixed layer show through.
Under prefers-reduced-motion: reduce, the wash layer is never created — sections simply keep their own solid backgrounds, which is the FOUNDATION-required "static tints" fallback and also, not coincidentally, the more conservative contrast guarantee.
The gooey blob field & the butter-melt cursor
The hero sits on a metaball-style blob field: an SVG <filter> blurs several soft radial-gradient circles, then a feColorMatrix sharpens the alpha channel back up, which is the classic "goo" trick — blurred edges become crisp again, but two shapes that overlap while blurred fuse into one continuous blob instead of two circles:
<filter id="goo">
<feGaussianBlur stdDeviation="15" result="blur"/>
<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0
0 0 1 0 0 0 0 0 20 -9"/>
</filter>
The five circles drift on independent, slow CSS keyframe loops (18–30s, out of phase) so the field never repeats a pose. Under reduced motion the blobs simply stop moving — still visible, still gooey, just still.
The cursor's "butter melt" ripple is a pointer-events-none <span> spawned on pointermove (throttled to roughly 14 per second), radial-gradient butter-gold, mix-blend-mode: multiply so it reads as a warm stain rather than a sticker, animating from a small dense dot to a soft faded pool over 0.9s before removing itself. It only runs on pointer: fine devices and never runs under reduced motion — an entirely opt-in, decorative layer with no effect on layout or content.
Iteration log
Three passes, screenshots read like a creative director, findings acted on. What actually changed:
Pass 1 — Design critique
- Full-page screenshots looked scrambled — wrong colors, unrevealed text — which turned out to be the capture method, not the site: a full-page screenshot resizes the viewport instead of scrolling it, so
scrollY-driven code (the wash, the reveals) freezes at an unrepresentative value. Switched to scripted scroll-position screenshots at each chapter's actual center for every real QA pass from here on. - With real scrolling, two product renders had a visible construction seam: the Cocoa Repair tube's cap sat 4% below its body (a gap in the layout math), and the Hibiscus Glow pump's nozzle floated disconnected above-right of its head. Closed the tube gap and re-anchored the nozzle so both read as one continuous object.
- The "Join the waitlist" pill button was clipping its own last letter against the rounded cap — insufficient horizontal padding for a 999px border-radius. Increased padding and added
white-space: nowrapso the pill always fits its label. - The Shea and Hibiscus ingredient-ribbon drawings didn't read as their subjects (the shea branch looked like an aimless squiggle; the hibiscus flower looked like a spider). Redrew both as a proper branch-with-leaves-and-nut and a clean five-petal radial flower, matching the clarity of the cocoa and baobab drawings.
Pass 2 — Elevation
- The brief calls for "hero (jar + claim)" — the first build shipped the claim alone, with the jar appearing only in the Shea Rich chapter. Added a dedicated hero jar (labeled simply "Nkuto," the brand mark rather than one line's name) as the composition's centerpiece, floating gently above the claim inside the gooey blob field — now the single most screenshot-worthy frame on the page.
- Added the vertical line-dots wayfinding nav (desktop) tied to the same section-tracking as the wash, so you always know which of the four lines you're inside, with a hover label naming it.
- Gave the dropper bottle's liquid a meniscus highlight and the jar lid an inset highlight/shadow pair so both read as raised, dimensional parts rather than flat printed shapes.
Pass 3 — Ship quality
- Found a real bug in the waitlist success state:
.form-successhad an explicitdisplay: flexrule, which overrides the browser's built-in[hidden]styling — the confirmation message was rendering on every visit, not just after a submit. Added a.form-success[hidden] { display: none; }guard and verified with a scripted test: hidden on a normal load, correctly shown (and the form correctly hidden) only when the page loads with?success=true. - Ran a contrast pass across every opacity-muted text style on every chapter background; one failed — the hero's fictional-brand disclaimer at 62% opacity measured 3.88:1 on milk, under the 4.5:1 AA minimum for body text. Raised it to 78% opacity (5.67:1) and re-checked the rest, which all cleared AA.
- All three routes screenshotted at mobile/tablet/desktop with zero console errors; every nav, footer, anchor and cross-route link resolves. Reduced-motion re-verified with a scripted check: the wash layer never mounts, the blob field holds still, and moving the pointer spawns zero melt-ripple elements.
- Fonts confirmed self-hosted and loading (three woff2 files, all comfortably above 10 KB); first-view payload measured at roughly 111 KB uncompressed, fonts included; every word proofread against the four-line, five-color system.
Deploy
Static files on Netlify, deployed CI-style from the collection's repository. netlify.toml publishes the site root, sets immutable year-long caching on /assets/*, and adds the standard hardening headers. The waitlist is Netlify Forms — a plain HTML <form data-netlify="true"> with a honeypot field, no JavaScript required to submit.
This is one of 26 sites in Hannah Kwakye's collection — the shared thesis being that AI doesn't replace taste; it amplifies the designer who has it. See the design process for the thinking, or return to the collection.