How the Alto Roasters site was brewed.
A complete walkthrough of the concept, palette, typography, the scroll-drawn steam thread, the bag-label product cards, the imagery art direction, and how to deploy the whole thing — no build step, just three files and taste.
01The concept
Alto is a fictional specialty coffee roastery in the vein of Onyx or La Cabra: modern craft, not rustic café. No chalkboards, no burlap-texture backgrounds, no steaming-mug clip art. The organizing idea is simple:
Everything else follows from that: mono type for anything that reads like data, a chunky soft serif for anything that reads like a voice, and one loud accent color used sparingly enough to stay loud.
02Palette
Rich espresso brown + oat cream, with cobalt blue as the unexpected pop (persimmon was the alternate; cobalt reads more "design studio", less "harvest festival"). Caramel bridges the two on dark sections.
Contrast rules: body text is espresso on paper (≈13:1). Cobalt text on cream is reserved for labels 12px+ and darkened to #1f30c7 when it has to carry small text. On cobalt and espresso backgrounds, text is always cream/oat.
03Typography
| Role | Face | Why |
|---|---|---|
| Display | Fraunces (variable, opsz 9–144) | A chunky soft-serif with real personality — wonky italics, huge optical range. At opsz 144 the headlines get that dense, jammy "printed label" feel. |
| Body | Karla | A warm, slightly quirky grotesque. Friendly without being childish. |
| Data | Spline Sans Mono | Soft-cornered mono for altitude, process, ratios, hours — anything that would be stamped on a bag. |
One trick worth stealing: italicizing exactly one word per headline in cobalt (notice, filler, smoke) gives every section a consistent editorial wink without a single extra element.
04The scroll-drawn steam thread
The signature motion: a single cobalt line that meanders down the entire page like steam rising off a pour, drawing itself as you scroll. It's one SVG path stretched over the page:
<!-- absolutely positioned behind the content, full page height -->
<div id="thread-wrap" aria-hidden="true">
<svg viewBox="0 0 1440 8000" preserveAspectRatio="none">
<path id="steam-path" d="M 980 -20 C 1060 240, 820 420, ..."/>
</svg>
</div>
// classic line-draw, scrubbed to the whole document const len = path.getTotalLength(); gsap.set(path, { strokeDasharray: len, strokeDashoffset: len }); gsap.to(path, { strokeDashoffset: 0, ease: 'none', scrollTrigger: { trigger: document.body, start: 'top top', end: 'bottom bottom', scrub: 0.6 } });
preserveAspectRatio="none"lets one hand-drawn path stretch to any page height — the curves distort a little, which actually makes it look more like steam.- The wrapper height is set from
document.documentElement.scrollHeight(minus the footer) in JS and re-measured onresize/load, thenScrollTrigger.refresh(). - Keep it at ~0.5 opacity and behind everything (
z-index:0, content at 1) so it reads as atmosphere, not annotation.
05Bag-label cards + tilt hover
Each card is plain HTML: a color band (numeral + origin), a name, a dashed-rule mono spec list, tasting-note pills, a roast meter, and a price row. Two motion details make them feel physical:
Satisfying hover tilt
card.addEventListener('mousemove', e => { const r = card.getBoundingClientRect(); const px = (e.clientX - r.left) / r.width - .5; const py = (e.clientY - r.top) / r.height - .5; card.style.transform = `translateY(-10px) rotateX(${-py*7}deg) rotateY(${px*8}deg)`; });
- Wrap the grid in
perspective: 1400pxand give cardswill-change: transform. - Throttle with
requestAnimationFrame; onmouseleave, snap back with a springy cubic-bezier (.34,1.56,.64,1) so it overshoots slightly — that's the "satisfying" part. - Gate the whole thing behind
matchMedia('(hover:hover) and (pointer:fine)')so touch devices never fight it.
Roast meter that fills on scroll
A bordered track with a striped fill (repeating-linear-gradient at -45°). Width starts at 0 and animates to data-fill with power4.out when the card enters the viewport — same pattern as a skill bar, but the striping makes it read as "roast level" rather than UI chrome.
06Paper grain, stickers, tape
- Grain: a fixed full-viewport pseudo-element with an inline-SVG
feTurbulencedata-URI,mix-blend-mode: multiplyat ~6% alpha. One rule, whole site instantly feels printed. - Chunky offsets: buttons and chips use
box-shadow: 3px 3px 0 var(--ink)and move up-left on hover — the sticker/riso-print language. - Rotation: almost every decorative element is rotated 2–7°. Straight = software; slightly crooked = hand-applied label.
- The spinning badge is
<textPath>on a circle inside an SVG, rotated with a 16s CSS animation — no JS.
07Art direction — the imagery
All photography was generated with Nano Banana Pro via the Higgsfield MCP, then compressed to JPEG (sips -s format jpeg -s formatOptions 74). Each image came from a carefully art-directed brief — lighting, lens feel, palette, composition and mood specified shot by shot (warm golden light, espresso-brown + cream, editorial/documentary, appetizing, no text, no people or hands only) — and iterated until it landed already color-graded to the site.
08Other techniques worth stealing
- Self-drawing brew diagrams: each brew method is a tiny hand-styled SVG (stroke only, round caps). Every path gets the same dasharray/dashoffset treatment as the steam thread, staggered 0.12s, triggered when the card scrolls in — so the V60 literally sketches itself.
- Count-up stats: tween a plain object
{v:0}and writeMath.round(obj.v)into the element on update. No plugin needed. - Marquee ticker: two identical
<span>s in a flex track,translateX(-50%)keyframe,width: max-content. Seamless loop, zero JS. - Overflow discipline:
overflow-x: cliponhtml, body— rotated stickers and the diagonal ribbon can bleed without spawning a horizontal scrollbar. (clip, nothidden, so it can't become a scroll container.) - Reduced motion: a real
prefers-reduced-motionbranch — reveals set to visible, counters and meters jump to final values, marquee and badge-spin stopped in CSS.
09Deploying to Netlify
The site is three static files plus an assets folder — no build step, so deployment is drag-and-drop simple.
- Drag-and-drop: go to app.netlify.com/drop and drop the
alto-roastersfolder. Done — you get a live URL in seconds. - CLI:
npm i -g netlify-cli cd alto-roasters netlify deploy --prod --dir .
- Git-connected: push the folder to a repo, "Import from Git" in Netlify, set publish directory to
alto-roastersand leave the build command empty.
Optional but nice: add a _headers file for long-cache on /assets/*, and set Cache-Control: public, max-age=31536000, immutable since the images are content-hashed by name in practice.