← Back to Morning Theory

Design Guide

Building a brand that roasts in real time.

Morning Theory's whole identity is expressed as one continuous variable: the roast curve. This page documents the brief, the palette, the techniques, and how to build the same system yourself.

01 · The Brief

Warm lab, not a coffee shop mood board.

Morning Theory is a specialty coffee roastery and subscription business. The creative brief called for something between a café and a chemistry lab: cream paper, caramel-to-espresso gradient thinking, precision without being cold. The idea we landed on treats roast level not as a menu label but as a literal design system variable. One slider, dragged from Light to Dark, walks the entire site through the same color and material transformation a bean actually goes through in the drum: pale and papery, through caramel, to near-black espresso. Everything else, the copy, the type, the layout, stays disciplined and quiet so that transformation reads clearly.

The build brief in one line: interpolate a whole brand across a physical variable, and let the roast curve BE the design system, not just a hero decoration.

02 · Palette and Type

Four colors, one curve.

The palette is deliberately small: a paper background, a tan surface tone, a dark espresso ink, and three named roast colors. The trick is that those three roast colors are not a footnote, they are the exact three stops the site interpolates through as you drag the slider. Nothing is invented for the animation. It was already in the palette.

Paper#FAF4EA
Surface#F1E6D4
Ink#2E1F14
Light Roast#C98E4A
Medium Roast#7A4A21
Dark Roast#3B2314

Dragged across the full range, the page background travels this exact curve, eased so it stays light through most of the drag and darkens quickly near the end, the same way a bean visibly changes more in its last minute in the drum than its first.

0% Light50% Medium100% Dark
Display: Gloock
Coffee, proven daily.
Why Gloock

A slab-serif with the flavor of old apothecary and roast-chart signage. It reads as measured and a little scientific, which is exactly the "warm lab" note the brief asked for, without tipping into cold or corporate.

Body: Work Sans
Bright citrus, honeysuckle, black tea.
Why Work Sans

A clean, humanist grotesk that stays completely legible at every stop on the color ramp, including reversed light-on-espresso at the dark end, and never competes with Gloock's character in headings.

03 · The Techniques

How the twelve core effects work.

1Roast-curve re-theming

One value, t from 0 to 1, drives twelve CSS custom properties: background, surface, card, text, heading, border, shadow, steam, bean, bean-shine, roast, and glow. Background and surface interpolate between two hex stops with an eased cubic so the darkening feels non-linear. Text color is not interpolated at all: it is swapped based on the computed luminance of the current background, so contrast never sags in the middle of the drag.

function applyRoast(t){
  const e = easeInCubic(t);
  const bg = mixHex(bgFrom, bgTo, e);
  const isDark = luminance(hexToRgb(bg)) < 130;
  const text = isDark ? darkText : lightText;
  root.style.setProperty('--bg', bg);
  root.style.setProperty('--text', text);
  // ...ten more properties, same pattern
}

2CSS steam wisps

Three blurred, semi-transparent divs rise and fade above the hero cup on staggered delays. No canvas, no particles library, just filter:blur() plus a keyframe that moves each wisp up and slightly sideways while its opacity breathes in and out.

.steam{ background:var(--steam); filter:blur(6px);
  animation:steamRise 4.2s ease-in-out infinite; }
@keyframes steamRise{
  0%{opacity:0; transform:translateY(0) scale(.7);}
  55%{transform:translateY(-30px) translateX(6px) scale(1.05);}
  100%{opacity:0; transform:translateY(-58px) scale(1.3);}
}

3Animated brew-ratio diagrams

Each brew method is a tiny inline SVG. The pour stream uses pathLength="1", which lets the dash math stay simple regardless of the path's real length: dasharray and dashoffset are both set to 1, then dashoffset transitions to 0 once the card enters the viewport, drawing the pour in.

<path class="pour-path" pathLength="1"
  d="M70 46 C 78 60, 84 66, 86 78"/>

.pour-path{ stroke-dasharray:1; stroke-dashoffset:1;
  transition:stroke-dashoffset 1.1s ease; }
.brew-card.in-view .pour-path{ stroke-dashoffset:0; }

4Subscription card flip pricing

Each plan is a 3D card: a perspective wrapper, an inner element that rotates 180 degrees on click, and two absolutely-positioned faces with backface-visibility:hidden. The back face is pre-rotated 180 degrees so it lands right-side up after the flip.

.plan-card{ transform-style:preserve-3d;
  transition:transform .7s cubic-bezier(.5,.1,.15,1); }
.plan-card.flipped{ transform:rotateY(180deg); }
.plan-face{ position:absolute; inset:0; backface-visibility:hidden; }
.plan-back{ transform:rotateY(180deg); }

5The sight glass: a photograph that roasts

The hero photograph sits inside a circular drum window with a tick bezel and a gauge needle. It is graded live by the same dial that re-themes the page: five custom properties feed one CSS filter, so dragging toward French literally darkens the crema. The tint layer above it multiplies the current roast color into the image, and a contrast guard keeps every label readable at every stop.

.glass-photo img, .glass-video{
  filter:brightness(var(--ph-b)) saturate(var(--ph-s))
         contrast(var(--ph-c)) sepia(var(--ph-sep)); }
/* from JS, per dial position t: */
root.style.setProperty('--ph-b',  lerp(1.04, 0.60, t));
root.style.setProperty('--ph-tint', lerp(0.10, 0.52, t));

6The live pour: a courteous cinemagraph

A five-second, 176KB espresso-pour loop plays inside the same circular mask, over the still, graded by the same filter variables. It is seasoning, not the dish: it waits for the preload gate to exit, fades in only once it can actually play, pauses when scrolled out of the hero, and never loads at all under reduced motion or save-data. The photograph is always the fallback.

<video muted loop playsinline preload="none"
  poster="img/poster.jpg"><source src="img/loop.mp4"></video>

if(!reducedMotion && !connection.saveData){
  video.load();
  video.addEventListener('canplay', () =>
    video.play().then(() => video.classList.add('live')));
}

7The dawn scrub: one morning, pinned

The roastery section is a 370vh band with a sticky full-height stage inside. Scroll progress maps linearly, with no easing, to a virtual clock from 4:45am to 9:00am: the sun climbs an SVG horizon, seven log entries swap in sequence over two photographic moments (the drum fire, then the cupping table), and the last few percent of the band hands the dark room back to whatever roast theme your dial currently holds in one fast flood of light. The handoff color is computed per frame and routed through a warm amber midpoint, because mixing near-black straight into cream passes through gray, and gray is the one color a sunrise never is. Board ink swaps sides by live contrast check, not by position. Scrubbed motion tied 1:1 to scroll must stay linear; easing on a scrub reads as lag.

const p = clamp01(-rect.top / (rect.height - vh));
const q = clamp01(p / 0.9);           // the morning itself
const ho = clamp01((p - 0.955) / 0.045); // the theme handoff
time = lerp(285, 540, q); // minutes: 04:45 to 09:00

8The split-flap departure board

Tasting notes do not fade in, they flip in, like the destination board at a rail station. Each character is wrapped in its own span, shuffles through random glyphs on a fixed flick interval, then lands on its true letter, staggered left to right so the line resolves like a wave. The same engine runs the hero readout when the dial crosses a roast stage, the cupping cards in the menu set piece, and the notes inside the blend takeover. Words are kept in unbreakable spans so the line never re-wraps mid-flip, and reduced motion sets the text directly.

const FLAPSET='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789·';
cells[i].end = base + i*stagger + Math.random()*70;
// every ~46ms until then:
cell.textContent = FLAPSET[(Math.random()*FLAPSET.length)|0];

9The heat sweep

When the dial crosses a roast stage, the re-theme does not just happen, it travels. A soft thermal band, tinted the current roast color, sweeps down the viewport when you roast darker and up when you back off, blended with multiply or soft-light so it reads as heat moving through the page rather than an overlay. It is a one-shot Web Animations API run on a fixed element, transform and opacity only.

heat.animate(
  [{transform:'translateY(-70vh)', opacity:0},
   {opacity:.3, offset:.35},
   {transform:'translateY(110vh)', opacity:0}],
  {duration:840, easing:'cubic-bezier(.3,.6,.4,1)'});

10The specimen bench set piece

The menu fires once per visit, on scroll-in, and never hijacks the scroll. Rows land raw-paper pale (a sepia-washed filter on the bag photograph) and visibly roast to their true color on a stagger; whole beans cascade across each plate in ballistic arcs and settle into the piles already photographed at each bag's base, so the animation lands inside the photography; the cupping card flips in on the departure board; and a steam wisp curls off each row as it settles. Beans are tiny inline SVGs animated with precomputed keyframes, a parabolic arc plus tumble, with a two-frame micro-bounce at the end. Mobile spawns roughly half the beans; reduced motion gets the finished rows, simply visible.

const y = sy + (ty-sy)*(p*p)          // gravity
        - apex * Math.sin(Math.PI*p) * .5; // the arc
frames.push({transform:`translate(${x}px,${y}px)
  rotate(${r0+(r1-r0)*p}deg)`});
bean.animate(frames, {duration:640+rand(280),
  delay:row*190+240, easing:'linear', fill:'both'});

11Orbiting ingredients and the blend takeover

Each blend's three flavor notes exist as etched specimen plates that orbit the bag slowly, like electrons, chemistry-lab canon. The orbit is two counter-rotating animations: the ring spins one way, each chip spins the reverse at the same period, so the artwork stays upright while its position circles. Tap the bag and the orbit explodes outward as the bag zooms into a full-screen takeover that behaves like its own page: the URL updates through the history API, the back button closes it, Escape and tap-outside close it, and opening another blend swaps content in place instead of stacking. Deep links like #midnight open straight into it.

.orbit-spin{animation:orbitSpin 46s linear infinite;}
.chip-in{animation:orbitSpin 46s linear infinite reverse;}

btn.onclick = () => {
  row.classList.add('exploding');       // orbit scatters
  history.pushState({mt:slug}, '', '#'+slug);
  setTimeout(openTakeover, 150);        // bag becomes the page
};
window.onpopstate = e =>
  e.state?.mt ? open(e.state.mt) : close();

12Deterministic reveals

Scroll reveals here do not rely on IntersectionObserver alone. Observer callbacks can starve under programmatic or very fast scrolling, which leaves sections permanently hidden. Instead, the page's one rAF-throttled scroll handler checks each unrevealed element's rectangle against the live viewport and reveals it the moment it crosses 92 percent of the viewport height. Same visual result, no missed sections, one code path to debug.

function revealCheck(){
  const vh = innerHeight;
  pending = pending.filter(el => {
    const r = el.getBoundingClientRect();
    if(r.top < vh * 0.92 && r.bottom > -60){
      el.classList.add('in'); return false;
    }
    return true;
  });
}

04 · Replicate It

Build the same system, step by step.

  1. Pick your two end colors first. Choose a background pair (light and dark) that already exist somewhere in your brand palette rather than inventing new ones.
  2. Write a hex mixer. A small hexToRgb / lerp / rgbToHex trio is all the color math you need. No animation library required.
  3. Ease the ramp, don't linear it. An easeInCubic curve on the interpolation factor makes the transformation feel physical instead of mechanical.
  4. Swap text by luminance, not by position. Compute the resulting background's luminance and flip text color at a brightness threshold so contrast is guaranteed at every point in the drag.
  5. Push everything through CSS custom properties. Update :root variables from JS, then let ordinary CSS (background:var(--bg)) do the repainting. This is what makes the "whole site" re-theme without touching every element in JS.
  6. Throttle the input with requestAnimationFrame. Pointer or drag events fire far faster than the screen can usefully repaint; batch them into one update per frame.
  7. Keep one anchor color constant. Primary call-to-action buttons stay a fixed brand color throughout the ramp, so the "buy" action is never hard to find, even mid-drag.
  8. Respect reduced motion. Clamp animation and transition durations under a media query so the re-theme still functions, just without the crossfade.

05 · Tools Used

What actually built this page.

Claude Fable 5

Design direction, layout, copy, and the full implementation plan for the roast-curve system, built from the site brief end to end.

Hand-written HTML, CSS, and JS

No frameworks, no build step, no component libraries. Every effect on this site is native browser CSS and vanilla JavaScript in a single file.

Google Fonts

Gloock for display, Work Sans for body text. The only external requests this site makes.

requestAnimationFrame

One frame-throttled scroll handler powers the reveals, the dawn scrub, the count-ups, and the roast slider's updates. No animation library anywhere.