Design Guide

Atmosphere systems for a wood-fire steakhouse: how the ember canvas, the photographic dry-age time-lapse, the photographed butcher's plate with live hit regions, and the ambient hearth loop were built, and how to build your own version of each.

The Brief

Charcoal-dark luxury, lit from inside

Ember & Oak is a wood-fire steakhouse. The brief called for embers, oak, and brass, appetite sold by firelight. The whole site had to feel like it was lit by one live hearth, never by a lightbulb, and every photograph on the page had to obey that same light.

Seven systems carry the feeling: a constant field of drifting embers above every section, a slow breathing glow that warms each heading as it enters view, a five-keyframe photographic time-lapse in which forty five days of dry aging pass as you scroll, a photographed steer whose eight primals answer your finger, one five second hearth loop behind the private room's glass wall, a sear-on-hover menu that mimics a flame catching, and dry-age counters that count up like a number worth waiting for.

The reservation flow was built to feel like a request to a private room rather than a booking widget: a short, personal form that answers with a written confirmation instead of a green checkmark.

Palette & Type

Fire needs somewhere dark to show up

The palette is built around near-black backgrounds so the ember and brass tones read as light sources rather than decoration. Every accent color had to survive being seen at low opacity, since most of it appears inside glows and gradients, not as flat fills.

#120D0ABackground, the room at night
#1D1512Surface, card and panel fill
#EFE5D8Text, warm porcelain
#E2792FEmber, the active flame
#C9A96ABrass, hairlines and price
#8C2F1BChar, the coal beneath the flame
Display, EB Garamond
Ember & Oak
Body, Karla
Every steak lists its age in days for one reason: it is the single biggest thing we do to it before the fire ever touches it.

EB Garamond carries every headline and price because it is a serif with real warmth and just enough asymmetry to feel hand-set, like a menu printed for one evening only. It is unique to this site among the twenty-five in this collection. Karla runs the body copy underneath it: a plain, humanist sans that stays legible in low light and never competes with the display face for attention.

The Techniques

Seven systems, one fire

1. The photographic time-lapse (scroll is the clock)

The age room is five photographs of the same rib primal on one locked stage, day 1, 7, 21, 34, and 45, shot with an identical pedestal, lamp, and camera. The section pins, and a single scroll progress value becomes the room's clock: it picks the two neighbouring keyframes and resolves the later one over the earlier by opacity. Because the wall numeral, the journal entry, the weight and crust gauges, and the tick rail all read the SAME value, nothing can ever fall out of sync.

var dayF = 1 + progress * 44;          // one clock for everything
var seg = keyframeBefore(dayF);        // day 21 . . .
var t = (dayF - DAYS[seg]) / (DAYS[seg+1] - DAYS[seg]);
imgs[seg].style.opacity = 1;           // base frame
imgs[seg+1].style.opacity = t;         // resolving frame

The five frames are fetched and decoded one hundred seventy percent of a viewport before the door, so the crossfade never pops. The progress itself is smoothed toward its target by fourteen percent a frame, the catch-up lag that makes a scrubbed scene feel cinematic instead of stapled to the wheel. Pacing is tuned per device: about one viewport of runway per journal beat on desktop, and on a phone the whole pinned scene fits in two and a half viewports with three beats instead of five.

2. The photographed plate with live hit regions

The butcher's chart is a studio photograph of a real Black Angus steer on a dark stage. An SVG overlay shares its exact 1920 by 1080 coordinate space: eight region paths traced point by point to the animal's anatomy, drawn at rest as quiet brass hairlines, plus labels, pulsing hotspot dots, and invisible forty four pixel hit discs. The photo is pre-cropped to the overlay's own coordinate space, and because image and overlay scale as one object the regions can never drift off the animal at any width.

.steer-media{ overflow:hidden; aspect-ratio:1920/1080 }
.steer-inner{ position:absolute; width:100% }   /* img + svg move as one */

Touch gets its own grammar: a verb-first chip, Tap a cut, sits on the plate and fades after the first successful tap; every tap lights the region and swaps a butcher's ticket in beneath within a frame, with price, dry-age days, and wood. Hover behaviour lives behind a hover-capability media query so a phone never sees it, which also sidesteps the iOS first-tap trap.

3. The ambient hearth loop

The private room's fire is a five second video graded to match the photograph beneath it. It carries the full iOS contract, autoplay muted playsinline loop, attaches its source only when the section approaches, plays and pauses on an IntersectionObserver, and crossfades in only once it is truly playing so the poster never cuts. Low Power Mode refuses all autoplay, so the play() promise rejection is caught and answered with a designed control instead of a dead browser glyph.

var vp = video.play();
if (vp && vp.catch) vp.catch(function(){
  offerTheFlame();   // styled tap-to-play, 44px, brass ring
});

4. Canvas ember drift

A single fixed, full-viewport canvas sits above the page and renders three fire systems at once: a coal bed of pulsing light stones along the viewport floor, embers rising with a sine-wave heat wobble (the bright ones leave a short streak), and burst embers the menu spits out on touch. Every particle is a radial gradient drawn twice, a wide soft halo and a hot core, composited with globalCompositeOperation = 'lighter' so overlapping embers brighten each other like real light instead of stacking flat color.

ctx.globalCompositeOperation = 'lighter';
p.x += Math.sin(p.wob) * 0.6 + p.drift;   // heat wobble
p.y -= p.vy;                              // upward drift
var g = ctx.createRadialGradient(x, y, 0, x, y, r * 4);
g.addColorStop(0, 'rgba(' + c + ',' + a + ')');   // halo
g.addColorStop(1, 'rgba(' + c + ',0)');           // to nothing

One variable, emberDim, ties the fire to the story: while the Age Room covers the viewport the whole canvas fades to almost nothing, because no fire burns inside a cure room at 34 degrees. Counts are capped at 88 embers on desktop and 40 under 700 pixels, device pixel ratio is clamped at 1.5, the loop pauses on visibilitychange, and reduced motion gets a single static frame of coals instead of the loop.

5. Fire-glow breathing gradients

Every section title carries its own glow as a ::before pseudo-element sized to its own box, a blurred radial gradient animated on a slow keyframe loop so it feels like it is breathing rather than pulsing. Tying the glow to the heading itself, instead of the whole section, keeps it hugging the words at any heading length rather than floating in whatever empty space the section happens to have. An IntersectionObserver adds an .in-view class as the section enters the viewport, which raises the glow's resting opacity, the heading visibly warms up as you arrive.

.h2{ position:relative; isolation:isolate; }
.h2::before{
  content:''; position:absolute; left:24%; top:50%;
  width:64%; height:150%; transform:translate(-50%,-50%);
  background:radial-gradient(ellipse,var(--glow-soft),transparent 68%);
  filter:blur(34px); z-index:-1; opacity:0;
}
.h2.in-view::before{
  opacity:1; animation:heartbeat 7s ease-in-out 1.4s infinite;
}
@keyframes heartbeat{ 0%,100%{opacity:.55} 50%{opacity:1} }

6. Sear-on-hover menu

Hovering a menu item triggers three things at once. The row's bottom rule ignites: a gradient running char to ember to brass scales in from the left like a flame catching along a fuse. The row's inner wrapper takes a very short alternating translateX shake, a sizzle, tuned small enough that it never looks like a layout bug. And the canvas above the page spits a dozen real burst embers from the row's bottom edge. All of it is gated behind @media (hover:hover) so a phone never sees a dead hover state; on touch, a tap fires the same sear for a moment.

.cut::after{
  background:linear-gradient(90deg,var(--char),var(--ember) 45%,
    var(--ember-hi) 70%,var(--brass));
  transform:scaleX(0);transform-origin:left;
  transition:transform .55s cubic-bezier(.16,1,.3,1);
}
@media(hover:hover){
  .cut:hover::after{ transform:scaleX(1) }
  .cut:hover .cut-inner{ animation:sizzle .42s ease-in-out }
}

7. Count-up dry-age counters

Every number that matters on this site, the dry-age days, the hearth temperature, the table count, is a <span class="counter" data-target="45">. A shared observer watches for each one to scroll into view, then animates from zero to its target with an eased requestAnimationFrame loop rather than a library, so the numbers land with a slight settle instead of ticking linearly.

var eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(target * eased);
Replicate It

Build your own version

Pick a light source, then a palette that needs it

Start from near-black. Choose one warm accent that will act as the only source of light on the page, everything else should read as reflected or ambient by comparison.

Build the atmosphere layer first

Add the fixed canvas before you write a single section. It should already look and feel right against a blank dark background before any content sits on top of it.

Make headings breathe, not blink

Attach a blurred radial glow to each section title and give it a slow easing keyframe, six to nine seconds per cycle. Anything faster reads as an alert, not ambience.

Shoot change, not props

If a photograph must tell a story of time, lock the stage: same subject, same lamp, same camera, and let only the thing that changes change. Five honest keyframes crossfaded beat any morph.

Give hover a physical verb

Instead of a generic color change, decide what the object would actually do, sear, ring, spark, and animate that specific verb on hover.

Design the finger's grammar separately

Hover is preview, tap is commit. Mark every interactive region with a resting dot, say the verb out loud on the canvas, keep hit areas at forty four pixels, and let taps swap one panel rather than stack many.

Let numbers earn their reveal

Any number worth bragging about (age, heat, capacity) should count up on arrival, not just appear. It buys a half second of attention for free.

Make the ask feel personal

For a reservation or booking flow, write it like a person will read it, not a database. Confirm with a sentence, not a checkmark icon.

Tools Used

What actually built this

No frameworks, no build step. One HTML file, inline CSS and JavaScript, two Google Fonts, real photography end to end: five aging keyframes on a locked stage, a studio steer, a stone cellar archway, a brass double door, four stage medallions, and one five second loop.

GalloWave · design system, copy, and full implementation
Hand-written HTML/CSS/JS
Canvas 2D API
IntersectionObserver
Bespoke keyframe photography
SVG hit-region overlay
Google Fonts