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.
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.
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.
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);
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.
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.