heroes /
Deep Zoom (Scroll Dives Into the Detail)
A pinned hero that falls into one detail of a photograph as you scroll — a kitchen down to the crystals in the granite. Three photographs hand over to each other on one shared exponential so an 18x dive stays sharp the whole way down. Structure measured off mercury.com; their version is a scrubbed video, this one is stills.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
/**
* Deep Zoom hero — the page falls INTO one detail of a photograph.
*
* Modelled on mercury.com's homepage hero (a desk on a hillside; scroll and the
* camera dives until the monitor is the whole screen). Their structure was read
* off the live page rather than guessed:
*
* section 1442 x 2023 relative
* stage 1442 x 809 sticky, h-screen, overflow hidden
* media aspect-locked, m-auto, object-cover
* grain a full-bleed SVG noise layer over the media
* copy z-10, centred
*
* So the dive is spent over 1213px of scroll — almost exactly 1.5 viewport
* heights — and the whole thing happens inside one pinned viewport.
*
* ── THE ONE THING WORTH KNOWING BEFORE YOU COPY THIS
* Mercury does not do the zoom in CSS. Their media element is a VIDEO —
* 'hero-scrub-lg.mp4', with -md and -sm cuts behind media queries and a
* 3840x1440 still as the poster — and scroll drives currentTime. The dive is
* pre-rendered, so detail exists at every depth because an artist put it there.
*
* We cannot scrub a video we do not have, and generating one per client is not
* a thing a component can do. So this rebuilds the same READ from stills, and
* the whole design is about the one problem that creates: a photograph has a
* fixed number of pixels, and a 18x zoom into it has nothing left to show.
*
* ── THE PLATE PYRAMID IS THE ANSWER, AND IT IS THE COMPONENT
* Instead of one photograph zoomed 18x, use three photographs zoomed ~3x each:
* a wide shot, a closer shot, a macro. Each one is natively sharp across its
* own stretch of the dive and hands over before it runs out of pixels. It is a
* mipmap chain walked in reverse.
*
* The handover only reads as one continuous move if two things hold, and both
* are easy to get wrong:
*
* 1. EVERY PLATE RIDES ONE EXPONENTIAL. Total zoom is the product of the
* per-plate zooms, and plate i is drawn at exp(p*totalLog - offset_i) —
* which is exactly 1.0 at the instant it takes over and keeps climbing on
* the identical curve the outgoing plate is still climbing. Two plates
* cross-fading at different rates shear against each other and the eye
* catches it every time. One curve, offset per plate, cannot shear.
* Exponential and not linear because a dolly-in at a constant apparent
* speed IS exponential in scale — linear scale reads as slow-then-lunging.
*
* 2. PLATE i AT SCALE 1 MUST FRAME WHAT PLATE i-1 FRAMES AT FULL ZOOM. That
* is what makes the cross-fade invisible: it is not a cut between two
* views, it is the same view arriving in focus. Shoot (or generate) the
* chain by deciding the target first and stepping in toward it.
*
* ── WHY THE OUTGOING PLATE BLURS, AND WHY THAT IS NOT A CHEAT
* A real lens pushed toward a surface throws everything off the focal plane
* soft, so blurring the plate being left behind is what the photograph would
* actually do. It also happens to hide the two things that would otherwise give
* the trick away — the outgoing plate's upscaling mush, and any mismatch
* between the two frames. Physics and concealment want the same thing here.
*
* ── AND WHY THERE IS GRAIN OVER ALL OF IT
* Mercury's grain layer looks like an aesthetic. Put a zoom under it and it
* stops being one: a fine noise field gives the eye a sharp, correctly-scaled
* detail to hold at every depth, so an upscaled plate underneath reads as film
* softness rather than as a low-resolution image. Turn 'grain' off and the deep
* end of the dive immediately looks cheap.
*
* ── THE INSET IS FOR DIVING INTO A SCREEN
* When the target is a display — a laptop, a phone, a kiosk — the last plate
* should not be a photograph at all. Give a plate an 'inset' rect and its
* children are drawn as LIVE DOM, glued to that rectangle of the photograph and
* re-laid-out every frame at the size the dive has reached. Nothing to
* cross-fade, and the UI is pin-sharp at 6x because it is text, not pixels.
*
* That rect is measured off the photograph, not eyeballed: find the screen's
* bounding box in the source image and express it as percentages of the image
* box. Then INSET IT for the bezel — with the display off, the bezel is exactly
* as black as the glass, so the rectangle a dark-pixel scan finds is both, and
* filling all of it paints the interface out over the bezel and onto the lid.
* See 'heroes/deep-zoom-screen' for the numbers and the arithmetic.
*
* ── WHY THE INSET IS NOT SIMPLY A CHILD OF THE SCALED PLATE
* Because it would be blurry. A transformed element that the compositor has
* promoted gets rasterised once at its layout size and then scaled as a bitmap,
* so DOM inside a scale(6) parent is a 6x-magnified raster of small text. The
* inset is therefore a SIBLING, and its rect is recomputed in px each frame
* from the same scale the plate is using, with font-size driven off that scale
* and everything within it sized in em. It lays out at its real size, so the
* browser rasterises it at its real size, so it is sharp.
*
* ── THE STATIC STATE IS A REAL PAGE
* Every rule that moves anything lives under '.dz.is-motion', added by the
* effect and nowhere else. With no JS, a thrown error, or reduced motion, this
* is the first plate, both pieces of copy stacked, and — where there is one —
* the screen shown at a legible size below. Nothing sits at opacity 0 waiting
* for a scroll handler that is never going to run.
*/
export interface DeepZoomInset {
/** The screen's rect as PERCENTAGES of the plate's own image box. */
x: number;
y: number;
w: number;
h: number;
/** Corner radius at 1x, in px of the image box. Scales with the dive. */
radius?: number;
/** Font size at 1x, in px. Everything inside should be sized in em off it. */
baseFont?: number;
}
export interface DeepZoomPlate {
/** The photograph. Pass null for the built-in placeholder wash. */
src?: string | null;
alt?: string;
/** Native aspect of the photograph, width / height. Governs the image box. */
aspect?: number;
/**
* Where this plate is diving TOWARD, as percentages of its own image box.
* This is the transform-origin, and on the last plate it is also where the
* dive comes to rest.
*/
focus?: { x: number; y: number };
/**
* How far THIS plate zooms across its own stretch. The plate hands over the
* moment it reaches this. Keep the LAST plate low — it is the one still on
* screen when the dive ends, so it is the one whose pixels run out in public.
*/
zoom?: number;
/** Live DOM glued to a rectangle of this plate. See the note above. */
inset?: DeepZoomInset | null;
}
export interface DeepZoomHeroProps {
brand?: string;
/**
* A logo to use instead of the wordmark. Sized by HEIGHT, because a mark is
* usually squarer than a word and it is the height that has to sit in a nav
* row without bursting it.
*/
brandLogo?: string | null;
brandLogoHeight?: number;
navLinks?: { label: string; href: string }[];
navCta?: { label: string; href: string } | null;
/**
* Progress at which the site nav fades out. Null keeps it, which is right
* over a photograph — it is the only thing telling the viewer they are still
* on a website. Set it when the dive lands on an interface: the app has its
* own chrome, and two sets of navigation in the same corner read as a
* rendering fault rather than as a hero.
*/
navOut?: number | null;
/** Copy over the wide shot. Leaves early. */
eyebrow?: string;
headline?: string;
sub?: string;
cta?: { label: string; href: string } | null;
/** Copy at the bottom of the dive. Arrives late. */
endHeadline?: string;
endSub?: string;
endCta?: { label: string; href: string } | null;
/** Progress at which the closing copy starts arriving. */
endIn?: number;
/**
* Progress at which it LEAVES again. Null keeps it on screen, which is right
* when the target is a texture and the copy is the point. Set it when the
* target is an interface: the reference ends in the product with nothing on
* top of it, and a headline parked over a UI turns the payoff into a caption.
*/
endOut?: number | null;
/** The pyramid, wide first. One plate works; three is the point. */
plates?: DeepZoomPlate[];
/** Anything to draw inside the first plate's inset rect. */
screen?: ReactNode;
/**
* Scroll the pinned stage holds for, as a percentage of viewport height.
* Mercury spends 1213px over an 809px viewport, which is 150.
*/
diveVh?: number;
/** Cross-fade length, as a fraction of one plate's stretch. */
fade?: number;
/** Peak blur on a plate being handed off, in px. */
blur?: number;
/**
* Blur laid on the LAST plate over the tail of the dive, in px. Zero when the
* last plate IS the subject — a macro that goes soft as you arrive on it is
* just a soft photograph. Raise it when the subject is an inset screen: the
* focal plane is the display, so the room around it has to fall away, and
* that is also what hides a wide shot being asked for 5x more pixels than it
* has.
*/
endBlur?: number;
/** Film grain over everything. See the note above before turning it off. */
grain?: boolean;
/** Darkening behind the opening copy, 0 to 1. */
scrim?: number;
/**
* How much of an inset screen the dive may crop, as a multiple of the scale
* that would have fitted it whole. 1 never crops and always letterboxes;
* 1.25 fills a normal desktop frame edge-to-edge and keeps a phone from
* ending the dive inside a single message bubble.
*/
insetCrop?: number;
ink?: string;
bone?: string;
accent?: string;
}
/* Placeholder wash, so the component renders with no assets at all. Warm greys
rather than a colour, for the same reason photo-dock uses them: a coloured
placeholder beside one real photograph reads as a bug, a grey one reads as an
empty slot, which is what it is. */
function wash(i: number) {
const tones = [
["#8a857e", "#b3aea7", "#d2cec9"],
["#7d7871", "#a49f98", "#c6c2bd"],
["#6f6a64", "#948f89", "#b8b4af"],
][i % 3];
return [
"radial-gradient(60% 40% at 50% 58%, rgba(255,255,255,.55), rgba(255,255,255,0) 70%)",
"linear-gradient(180deg, " + tones[0] + " 0%, " + tones[1] + " 55%, " + tones[2] + " 100%)",
].join(", ");
}
const GRAIN =
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160">' +
'<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>' +
'<feColorMatrix type="saturate" values="0"/></filter>' +
'<rect width="160" height="160" filter="url(#n)" opacity="0.5"/></svg>'
);
/**
* The default screen: Hermes, as it is actually used.
*
* Laid out to be READ AT THE BOTTOM OF THE DIVE, which is a different problem
* from looking right in a screenshot. Two things that had to change once it was
* seen at full size:
*
* Content flows from the TOP. A chat pinned to the bottom of its container is
* correct in a real client and leaves a large empty field here, because the
* frame is much taller than a thread of three messages. An empty app is the
* opposite of the argument this hero is making.
*
* The frame CROPS about 8% off the top and bottom. The screen is 1.51:1 and
* the viewport is wider than that, so filling the width overflows the height.
* Nothing load-bearing goes in those bands — the title bar and the composer
* are what get trimmed, and both are recognisable from a sliver.
*/
function HermesScreen() {
return (
<div className="dz-scr">
<div className="dz-scr__bar">
<span className="dz-scr__dots" aria-hidden="true">
<i /><i /><i />
</span>
<span className="dz-scr__title">Hermes</span>
</div>
<div className="dz-scr__body">
<aside className="dz-scr__side">
<span className="dz-scr__brand">Hermes</span>
<ul className="dz-scr__nav">
<li className="is-on">Today</li>
<li>Threads</li>
<li>Memory</li>
<li>People</li>
</ul>
<span className="dz-scr__label">Remembered</span>
<ul className="dz-scr__mem">
<li>Install cadence — one a week</li>
<li>Alvarez — kitchen, phase two</li>
<li>Doug — prefers mornings</li>
<li>Thursday is a writing day</li>
</ul>
</aside>
<div className="dz-scr__thread">
<div className="dz-scr__day">Today</div>
<div className="dz-scr__msg dz-scr__msg--them">
<span className="dz-scr__who">Hermes</span>
Morning. Three things moved overnight — the Alvarez quote came back,
Thursday shifted an hour, and last night’s notes are in the vault.
</div>
<div className="dz-scr__msg dz-scr__msg--me">
What did Alvarez say about the timeline?
</div>
<div className="dz-scr__msg dz-scr__msg--them">
<span className="dz-scr__who">Hermes</span>
They can start the week of the 14th. You told me in June you did not want
two installs running the same week, so I have flagged the overlap with Doug.
<span className="dz-scr__cite">from your notes, 11 June</span>
</div>
<div className="dz-scr__msg dz-scr__msg--me">
Move Doug to the 21st and tell him why.
</div>
<div className="dz-scr__msg dz-scr__msg--them">
<span className="dz-scr__who">Hermes</span>
Done. Draft is in your outbox, not sent.
</div>
<div className="dz-scr__msg dz-scr__msg--me">
What was the number we landed on for the island?
</div>
<div className="dz-scr__msg dz-scr__msg--them">
<span className="dz-scr__who">Hermes</span>
Four thousand eight hundred, with the waterfall edge. You worked it out on
the phone in the truck and told me afterwards.
<span className="dz-scr__cite">from your notes, 3 August</span>
</div>
<div className="dz-scr__row">
<span className="dz-scr__input">Ask Hermes anything</span>
<span className="dz-scr__send" aria-hidden="true" />
</div>
</div>
</div>
</div>
);
}
const KITCHEN_PLATES: DeepZoomPlate[] = [
{
src: "/heroes/deep-zoom/kitchen-wide.webp",
alt: "A bright kitchen with a dark speckled granite island running across the room",
aspect: 1376 / 768,
focus: { x: 50, y: 62 },
zoom: 3.2,
},
{
src: "/heroes/deep-zoom/granite-mid.webp",
alt: "The polished granite counter close up, morning light across it",
aspect: 1376 / 768,
focus: { x: 50, y: 58 },
zoom: 3.0,
},
{
src: "/heroes/deep-zoom/granite-macro.webp",
alt: "Macro of the granite — white feldspar, amber and quartz in a charcoal ground",
aspect: 1376 / 768,
focus: { x: 52, y: 45 },
zoom: 1.9,
},
];
export default function DeepZoomHero({
brand = "Rocky Mountain Stone",
brandLogo = "/heroes/deep-zoom/rms-logo.svg",
brandLogoHeight = 74,
navLinks = [
{ label: "Work", href: "#" },
{ label: "Materials", href: "#" },
{ label: "Studio", href: "#" },
],
navCta = { label: "Book a visit", href: "#" },
navOut = null,
eyebrow = "Kitchens, Albuquerque",
headline = "One slab, chosen in the yard",
sub =
"We pick the stone before we draw the kitchen. Every counter we set is a single piece, cut once, so the pattern runs unbroken from the sink to the far edge.",
cta = { label: "See the stone", href: "#" },
endHeadline = "No two feet of it are the same",
endSub = "Cut and polished in our own shop. You pick the slab off the rack.",
endCta = { label: "Book a visit to the yard", href: "#" },
endIn = 0.72,
endOut = null,
plates = KITCHEN_PLATES,
screen = null,
diveVh = 150,
fade = 0.34,
blur = 16,
endBlur = 0,
grain = true,
scrim = 0.34,
insetCrop = 1.25,
ink = "#1a1917",
bone = "#ffffff",
accent = "#c98a3c",
}: DeepZoomHeroProps) {
const rootRef = useRef<HTMLElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const list = plates.length ? plates : KITCHEN_PLATES;
useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root || !stage) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const plateEls = Array.from(stage.querySelectorAll<HTMLElement>(".dz__plate"));
const insetEl = stage.querySelector<HTMLElement>(".dz__inset");
if (!plateEls.length) return;
// ONE CURVE, OFFSET PER PLATE. weights are logs, so a plate's offset is
// where on the shared exponential it becomes life-size, and its scale is
// exp(travelled - itsOffset). See the header note — this is the only reason
// the hand-offs do not shear.
//
// Rebuilt on resize rather than computed once, because an inset plate's
// zoom is not a number somebody chose, it is MEASURED — see below.
let offsets: number[] = [];
let enter: number[] = [];
let exit: number[] = [];
let totalLog = 1;
const buildCurve = () => {
const stageW = stage.clientWidth;
const stageH = stage.clientHeight;
const zooms = list.map((plate, i) => {
const authored = Math.max(1.01, plate.zoom ?? 3);
const rect = plate.inset;
const host = plateEls[i];
if (!rect || !host || !stageW || !stageH) return authored;
// AN INSET'S ZOOM IS GEOMETRY, NOT TASTE, and it cannot be a constant
// because the plate box is sized to cover the viewport: on a portrait
// phone the box is three times wider than the screen, so a number that
// lands the interface edge-to-edge on a laptop rockets past it and ends
// the dive somewhere inside one message bubble.
//
// cover fills the frame and crops; contain fits it whole. Wide screens
// want cover — the reference ends INSIDE the product, not looking at a
// picture of it. Narrow ones cannot have it: a 1.5:1 interface in a
// 0.46:1 viewport has to lose two thirds of its width to fill the
// height. So take cover, but never crop more than insetCrop past what
// would have fitted. Desktop is unaffected and mobile lands somewhere
// sane instead of nowhere.
const iw = (rect.w / 100) * host.offsetWidth;
const ih = (rect.h / 100) * host.offsetHeight;
if (iw <= 0 || ih <= 0) return authored;
const cover = Math.max(stageW / iw, stageH / ih);
const contain = Math.min(stageW / iw, stageH / ih);
return Math.max(1.01, Math.min(cover, contain * insetCrop));
});
const weights = zooms.map((z) => Math.log(z));
totalLog = weights.reduce((a, b) => a + b, 0) || 1;
offsets = [];
weights.reduce((acc, w, i) => {
offsets[i] = acc;
return acc + w;
}, 0);
// Where each plate takes over and where it is done, in 0..1 progress.
enter = offsets.map((o) => o / totalLog);
exit = offsets.map((o, i) => (o + weights[i]) / totalLog);
};
buildCurve();
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const seg = (p: number, a: number, b: number) => clamp01((p - a) / (b - a));
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const travel = () => root.offsetHeight - window.innerHeight;
const progress = () => {
const t = travel();
return t > 0 ? clamp01(-root.getBoundingClientRect().top / t) : 0;
};
let raf = 0;
let running = false;
const tick = () => {
raf = 0;
const p = progress();
const travelled = p * totalLog;
// Scale each plate on the shared curve, and cross-fade at each hand-over.
const scales: number[] = [];
for (let i = 0; i < plateEls.length; i++) {
const el = plateEls[i];
const s = Math.max(1, Math.exp(travelled - offsets[i]));
scales[i] = s;
// Opacity: in over the fade window that starts where this plate takes
// over; out over the window where the NEXT one does. The last plate
// never fades out.
const span = exit[i] - enter[i];
const fadeLen = Math.max(0.001, span * fade);
const appearing = i === 0 ? 1 : seg(p, enter[i], enter[i] + fadeLen);
const leaving =
i === plateEls.length - 1 ? 0 : seg(p, enter[i + 1], enter[i + 1] + fadeLen);
const o = appearing * (1 - leaving);
el.style.setProperty("--s", s.toFixed(4));
el.style.setProperty("--o", o.toFixed(4));
// The plate being handed off goes soft. Physics, and it hides the
// upscaling at the same time.
const tail =
i === plateEls.length - 1 && endBlur > 0 ? seg(p, 0.5, 1) * endBlur : 0;
el.style.setProperty("--bl", (leaving * blur + tail).toFixed(2) + "px");
el.style.visibility = o < 0.002 ? "hidden" : "visible";
}
// THE INSET IS LAID OUT, NOT TRANSFORMED. Same scale as its plate, but
// written as real px so the text inside rasterises at its real size.
if (insetEl) {
const idx = Number(insetEl.dataset.plate ?? "0");
const plate = list[idx];
const rect = plate?.inset;
const host = plateEls[idx];
if (rect && host) {
const bw = host.offsetWidth;
const bh = host.offsetHeight;
const s = scales[idx];
const ox = (plate.focus?.x ?? 50) / 100;
const oy = (plate.focus?.y ?? 50) / 100;
// The plate box is centred in the stage and is usually LARGER than
// it, so its top-left is negative. The inset is a sibling positioned
// against the stage, so that offset has to come back in or the screen
// sits wherever the overflow happens to have pushed it.
const bx = (stage.clientWidth - bw) / 2;
const by = (stage.clientHeight - bh) / 2;
// A point in the unscaled box maps to origin + (point - origin) * s.
const ix = (rect.x / 100) * bw;
const iy = (rect.y / 100) * bh;
const left = bx + ox * bw + (ix - ox * bw) * s;
const top = by + oy * bh + (iy - oy * bh) * s;
const w = (rect.w / 100) * bw * s;
const h = (rect.h / 100) * bh * s;
insetEl.style.left = left.toFixed(1) + "px";
insetEl.style.top = top.toFixed(1) + "px";
insetEl.style.width = w.toFixed(1) + "px";
insetEl.style.height = h.toFixed(1) + "px";
insetEl.style.borderRadius = ((rect.radius ?? 6) * s).toFixed(1) + "px";
insetEl.style.fontSize = ((rect.baseFont ?? 3) * s).toFixed(2) + "px";
// THE SCREEN IS ON FROM THE FIRST FRAME. It used to wake over the
// first fifth of the dive, on the theory that a rectangle lighting up
// gives the eye a reason to travel toward it. Two problems, and the
// second one settled it. A dark laptop is a switched-off laptop, and
// the opening frame is the one most people see — a desk with the
// product running on it says more at rest than a wake does in motion.
// And the wake ran across exactly the same scroll as the opening copy
// leaving, so there was no frame anywhere in the hero with the screen
// lit AND the headline crisp. That frame is the thumbnail.
insetEl.style.setProperty("--wake", "1");
}
}
const s = stage.style;
s.setProperty("--a", String(1 - seg(p, 0.0, 0.26))); // opening copy out
const bIn = easeOut(seg(p, endIn, Math.min(1, endIn + 0.24)));
const bOut = endOut == null ? 0 : seg(p, endOut, Math.min(1, endOut + 0.1));
s.setProperty("--b", String(bIn * (1 - bOut))); // closing copy in, and away again
s.setProperty("--p", p.toFixed(4));
s.setProperty(
"--nav",
navOut == null ? "1" : String(1 - seg(p, navOut, Math.min(1, navOut + 0.12)))
);
};
const onScroll = () => {
if (!running || raf) return;
raf = requestAnimationFrame(tick);
};
const onResize = () => {
buildCurve();
onScroll();
};
const io = new IntersectionObserver(
(entries) => {
running = entries[0]?.isIntersecting ?? false;
if (running) onScroll();
},
{ threshold: 0 }
);
io.observe(root);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
// is-motion goes on LAST, after one tick has written every custom property.
// Add it first and the browser paints one frame of the animated layout with
// no numbers in it, which is a flash of the hero inside out.
running = true;
tick();
root.classList.add("is-motion");
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
root.classList.remove("is-motion");
};
}, [list, fade, blur, endBlur, endIn, endOut, navOut, insetCrop]);
const insetPlate = list.findIndex((p) => p.inset);
const screenNode = screen ?? (insetPlate >= 0 ? <HermesScreen /> : null);
return (
<section
ref={rootRef}
className="dz"
style={
{
"--ink": ink,
"--bone": bone,
"--accent": accent,
"--dive-vh": diveVh + "vh",
"--scrim": String(scrim),
"--grain": "url(\"" + GRAIN + "\")",
} as React.CSSProperties
}
>
<style>{CSS}</style>
<div className="dz__stage" ref={stageRef}>
<div className="dz__plates">
{list.map((plate, i) => (
<div
key={i}
className="dz__plate"
data-i={i}
style={
{
"--ar": String(plate.aspect ?? 16 / 9),
aspectRatio: String(plate.aspect ?? 16 / 9),
transformOrigin:
(plate.focus?.x ?? 50) + "% " + (plate.focus?.y ?? 50) + "%",
zIndex: i + 1,
} as React.CSSProperties
}
>
{plate.src ? (
<img src={plate.src} alt={plate.alt ?? ""} draggable={false} />
) : (
<div className="dz__wash" style={{ background: wash(i) }} aria-hidden="true" />
)}
</div>
))}
{insetPlate >= 0 && screenNode && (
<div className="dz__inset" data-plate={insetPlate}>
<div className="dz__inset-in">{screenNode}</div>
<span className="dz__glass" aria-hidden="true" />
</div>
)}
</div>
<div className="dz__scrim" aria-hidden="true" />
{grain && <div className="dz__grain" aria-hidden="true" />}
<div className="dz__vignette" aria-hidden="true" />
<header className="dz__nav">
{brandLogo ? (
<img
className="dz__brandlogo"
src={brandLogo}
alt={brand}
style={{ height: brandLogoHeight + "px" }}
/>
) : (
<span className="dz__brand">{brand}</span>
)}
<nav>
{navLinks.map((l) => (
<a key={l.label} href={l.href}>
{l.label}
</a>
))}
</nav>
{navCta && (
<a className="dz__navcta" href={navCta.href}>
{navCta.label}
</a>
)}
</header>
<div className="dz__copy dz__copy--a">
{eyebrow && <span className="dz__eyebrow">{eyebrow}</span>}
<h1>{headline}</h1>
{sub && <p>{sub}</p>}
{cta && (
<a className="dz__cta" href={cta.href}>
{cta.label}
</a>
)}
<span className="dz__cue" aria-hidden="true">
<i />
</span>
</div>
<div className="dz__copy dz__copy--b">
<h2>{endHeadline}</h2>
{endSub && <p>{endSub}</p>}
{endCta && (
<a className="dz__cta dz__cta--solid" href={endCta.href}>
{endCta.label}
</a>
)}
</div>
{insetPlate >= 0 && screenNode && (
<div className="dz__still">
<div className="dz__still-frame">{screenNode}</div>
</div>
)}
</div>
</section>
);
}
const CSS = `
.dz { position: relative; background: #0e0d0c; color: var(--bone); }
.dz * { box-sizing: border-box; }
/* Static height. is-motion buys the dive its scroll. */
.dz__stage {
position: relative;
height: 100vh; height: 100svh;
overflow: hidden;
display: grid;
}
.dz.is-motion { height: calc(100vh + var(--dive-vh)); height: calc(100svh + var(--dive-vh)); }
.dz.is-motion .dz__stage { position: sticky; top: 0; }
.dz__plates { position: absolute; inset: 0; overflow: hidden; }
/* THE IMAGE BOX IS ASPECT-LOCKED AND OVER-SIZED, not stretched to the stage.
Percentages inside a box whose shape matches the photograph mean the same
thing at every viewport, which is what lets the inset rect be measured off
the source image once and then trusted. object-fit on a stage-shaped element
would crop by an amount that changes with the window, and the laptop screen
would drift off the laptop. */
.dz__plate {
position: absolute;
top: 50%; left: 50%;
width: max(100%, calc(100vh * var(--ar, 1.79)));
width: max(100%, calc(100svh * var(--ar, 1.79)));
transform: translate(-50%, -50%) scale(var(--s, 1));
opacity: var(--o, 1);
filter: blur(var(--bl, 0px));
will-change: transform, opacity;
backface-visibility: hidden;
}
.dz__plate img, .dz__plate .dz__wash {
display: block; width: 100%; height: 100%; object-fit: cover;
user-select: none; -webkit-user-drag: none;
}
/* Without JS only the first plate exists; the others are the same picture with
less of it in frame, and stacking three of those is not a fallback. */
.dz:not(.is-motion) .dz__plate[data-i="1"],
.dz:not(.is-motion) .dz__plate[data-i="2"],
.dz:not(.is-motion) .dz__plate[data-i="3"] { display: none; }
/* AND IT STRETCHES, because the static section is taller than one viewport —
nav, two pieces of copy and (where there is one) the screen, all stacked. The
aspect-locked box is 813px tall against a 1647px section and centres itself,
which puts a black band above and below the photograph. Nothing in the static
state needs the box to keep the photograph's shape: the inset is hidden, so
there is no rectangle that has to stay glued to anything, and an honest crop
beats two black bands. */
.dz:not(.is-motion) .dz__plate {
top: 0; left: 0; width: 100%; height: 100%;
aspect-ratio: auto; transform: none;
}
.dz:not(.is-motion) .dz__scrim { opacity: calc(var(--scrim) * 0.8); }
/* The live screen. Laid out in px by the effect — see the note in the header
about why this is not simply a child of the scaled plate. */
.dz__inset {
position: absolute;
z-index: 40;
overflow: hidden;
background: #0b0d10;
box-shadow: 0 0 0 0.06em rgba(0,0,0,.55);
opacity: var(--wake, 0);
contain: paint;
}
.dz__inset-in { position: absolute; inset: 0; }
/* A lit screen seen across a room has a sheen on it. Fades out as we arrive,
because by then we are looking AT the interface, not at a laptop. */
.dz__glass {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(102deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,0) 38%);
opacity: calc(1 - var(--p, 0));
}
/* The inset only exists once the effect is driving it. Without JS it would sit
at 0x0 wherever the plate overflow left it, so the screen is rendered a
second time as an ordinary block instead — see .dz__still. */
.dz:not(.is-motion) .dz__inset { display: none; }
.dz__still {
position: relative; z-index: 60;
padding: clamp(16px, 3vh, 36px) clamp(20px, 4vw, 56px) 0;
}
.dz__still-frame {
position: relative; width: min(820px, 100%); aspect-ratio: 16 / 10;
border-radius: 12px; overflow: hidden; font-size: 12px;
background: #0b0d10; box-shadow: 0 24px 60px rgba(0,0,0,.45);
}
.dz.is-motion .dz__still { display: none; }
.dz__scrim {
position: absolute; inset: 0; z-index: 45; pointer-events: none;
/* Bottom-WEIGHTED, and the 62% stop is the one that matters. A gradient that
only darkens the last few percent leaves a three-line headline sitting on
bare photograph, and both plates here end bright — pale oak, white stone, a
lit window. Copy is white; the picture has to be made to hold it. */
background: linear-gradient(180deg,
rgba(12,11,10,.82) 0%, rgba(12,11,10,.16) 34%,
rgba(12,11,10,.50) 62%, rgba(12,11,10,.88) 100%);
opacity: var(--scrim);
}
/* THE SCRIM FOLLOWS WHICHEVER COPY IS ON SCREEN, not just the opening one.
Tying it to --a alone means it has faded to almost nothing by the time the
closing line arrives, and that line lands on whatever the dive happens to
have ended on — pale wood, a lit window, white stone. It was unreadable on
the office plate before this was max(). */
.dz.is-motion .dz__scrim {
opacity: calc(var(--scrim) * (0.3 + 0.7 * max(var(--a, 1), var(--b, 0))));
}
.dz__grain {
position: absolute; inset: 0; z-index: 46; pointer-events: none;
background-image: var(--grain); background-size: 160px 160px;
opacity: .16; mix-blend-mode: overlay;
}
.dz__vignette {
position: absolute; inset: 0; z-index: 47; pointer-events: none;
background: radial-gradient(120% 90% at 50% 50%, rgba(0,0,0,0) 52%, rgba(0,0,0,.5) 100%);
}
.dz__nav {
position: relative; z-index: 60;
display: flex; align-items: center; gap: clamp(16px, 3vw, 40px);
padding: clamp(18px, 2.4vw, 30px) clamp(20px, 4vw, 56px);
font: 500 14px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: .01em;
}
.dz__brand { font-weight: 620; font-size: 16px; letter-spacing: -.015em; margin-right: auto; }
/* Sized by height and given a real shadow. This particular mark is a WHITE CARD
with black type rather than a knockout, so it does not need contrast help —
but a hard white rectangle pasted on a photograph looks pasted, and a soft
shadow is what makes it sit on the picture instead of on the glass. */
.dz__brandlogo {
display: block; width: auto; margin-right: auto; flex: none;
filter: drop-shadow(0 6px 18px rgba(0,0,0,.42));
}
@media (max-width: 819px) { .dz__brandlogo { height: 54px !important; } }
.dz__nav nav { display: none; gap: clamp(14px, 2vw, 28px); }
.dz__nav a { color: inherit; text-decoration: none; opacity: .82; }
.dz__nav a:hover { opacity: 1; }
.dz__navcta {
padding: 9px 16px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.42); opacity: 1 !important;
}
@media (min-width: 820px) { .dz__nav nav { display: flex; } }
/* Once the copy is absolutely positioned the nav is the ONLY thing left in the
grid, so its row stretches to the whole stage and a flex row with
align-items:center lands the whole nav in the middle of the photograph. It
has to come out of flow too. */
.dz.is-motion .dz__nav {
position: absolute; top: 0; left: 0; right: 0;
opacity: var(--nav, 1);
}
.dz__copy {
position: relative; z-index: 60;
padding: 0 clamp(20px, 4vw, 56px) clamp(28px, 5vh, 60px);
max-width: 46rem;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.dz__copy h1, .dz__copy h2 {
text-shadow: 0 1px 26px rgba(0,0,0,.35);
margin: 0 0 .5em; font-weight: 600; letter-spacing: -.028em; line-height: 1.03;
font-size: clamp(2.3rem, 6.4vw, 4.6rem); text-wrap: balance;
}
.dz__copy p {
text-shadow: 0 1px 18px rgba(0,0,0,.4);
margin: 0 0 1.4em; max-width: 34rem; line-height: 1.55;
font-size: clamp(1rem, 1.35vw, 1.18rem); opacity: .88; text-wrap: pretty;
}
.dz__eyebrow {
/* The accent is a warm gold and both plates open on warm, bright walls, so
this is the one line the scrim does not rescue — it sits above the
bottom-weighted part of the gradient. It needs its own floor. */
text-shadow: 0 1px 14px rgba(0,0,0,.55);
display: inline-block; margin-bottom: 1.1em;
font-size: .74rem; letter-spacing: .16em; text-transform: uppercase;
color: var(--accent); font-weight: 600;
}
.dz__cta {
display: inline-block; text-decoration: none; color: inherit;
padding: 13px 24px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.5); font-weight: 550; font-size: .95rem;
transition: background-color .2s ease, color .2s ease;
}
.dz__cta:hover { background: var(--bone); color: var(--ink); }
.dz__cta--solid { background: var(--bone); color: var(--ink); border-color: var(--bone); }
.dz__cta--solid:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.dz__cue { display: none; }
/* Static: nav, opening copy, closing copy, then the screen. A page. */
.dz:not(.is-motion) .dz__stage {
height: auto; min-height: 100vh;
grid-auto-rows: max-content; align-content: start;
padding-bottom: clamp(28px, 6vh, 72px);
}
.dz:not(.is-motion) .dz__copy--a { padding-top: clamp(60px, 14vh, 160px); }
.dz:not(.is-motion) .dz__copy--b { padding-top: clamp(24px, 4vh, 48px); }
.dz.is-motion .dz__copy {
position: absolute; left: 0; right: 0; bottom: 0;
max-width: min(46rem, calc(100% - 8vw));
}
.dz.is-motion .dz__copy--a {
opacity: var(--a, 1);
transform: translate3d(0, calc((1 - var(--a, 1)) * -34px), 0);
pointer-events: none;
}
.dz.is-motion .dz__copy--b {
opacity: var(--b, 0);
transform: translate3d(0, calc((1 - var(--b, 0)) * 30px), 0);
pointer-events: none;
}
.dz.is-motion .dz__copy--a > *, .dz.is-motion .dz__copy--b > * { pointer-events: auto; }
/* Anchored to the LEFT EDGE OF THE TEXT COLUMN, not to 50% of it. Centring it
inside a max-width copy block lands it at neither the middle of the page nor
the middle of anything else, and it reads as a stray artefact rather than as
an instruction. */
.dz.is-motion .dz__cue {
display: block; position: absolute; left: clamp(20px, 4vw, 56px); bottom: 0;
width: 1px; height: 40px; background: rgba(255,255,255,.26); overflow: hidden;
}
.dz.is-motion .dz__cue i {
position: absolute; inset: 0; background: var(--bone);
animation-name: dz-cue; animation-duration: 2.1s; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
/* LONGHANDS ONLY. A minifier folds the shorthand and the browser drops the
whole declaration — the animation then dies in the built bundle and works in
dev, which is the worst pair of behaviours available. */
@keyframes dz-cue {
0% { transform: translateY(-100%); }
60%, 100% { transform: translateY(100%); }
}
@media (prefers-reduced-motion: reduce) {
.dz { height: auto !important; }
.dz__stage { position: static !important; height: auto !important; }
.dz__cue i { animation-name: none; }
}
/* ── the default screen ─────────────────────────────────────────────────── */
.dz-scr {
position: absolute; inset: 0; display: flex; flex-direction: column;
background: #f6f5f3; color: #17181a; overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 1em; line-height: 1.45;
}
.dz-scr__bar {
display: flex; align-items: center; gap: .8em; flex: none;
padding: .62em 1.1em; background: #eae8e5; border-bottom: 1px solid #dcdad6;
}
.dz-scr__dots { display: flex; gap: .42em; }
.dz-scr__dots i { width: .6em; height: .6em; border-radius: 50%; background: #cfcdc8; }
.dz-scr__dots i:first-child { background: #e0736a; }
.dz-scr__dots i:nth-child(2) { background: #e0b25a; }
.dz-scr__dots i:nth-child(3) { background: #7bb46a; }
.dz-scr__title { font-size: .78em; font-weight: 560; opacity: .55; }
.dz-scr__body { display: flex; flex: 1; min-height: 0; }
.dz-scr__side {
flex: none; width: 23%; padding: 1.1em 1em; background: #edebe8;
border-right: 1px solid #dcdad6; display: flex; flex-direction: column; gap: .9em;
}
.dz-scr__brand { font-weight: 650; font-size: 1.06em; letter-spacing: -.02em; }
.dz-scr__nav, .dz-scr__mem {
list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .22em;
}
.dz-scr__nav li { font-size: .82em; padding: .38em .58em; border-radius: .42em; opacity: .62; }
.dz-scr__nav li.is-on { background: #17181a; color: #fff; opacity: 1; }
.dz-scr__label {
font-size: .64em; font-weight: 660; letter-spacing: .13em; text-transform: uppercase;
opacity: .38; margin-top: .3em;
}
.dz-scr__mem li {
font-size: .74em; padding: .34em 0 .34em .7em; opacity: .58;
border-left: 1.5px solid #d3d0cb; line-height: 1.35;
}
/* Top-down, not bottom-pinned. See the note above the component. */
.dz-scr__thread {
flex: 1; min-width: 0; padding: 1.1em 1.4em 1em; display: flex; flex-direction: column;
gap: .6em; overflow: hidden;
}
.dz-scr__day {
align-self: center; font-size: .66em; font-weight: 620; letter-spacing: .1em;
text-transform: uppercase; opacity: .34; margin-bottom: .3em;
}
.dz-scr__msg {
max-width: 74%; padding: .7em .95em; border-radius: .95em; font-size: .84em;
}
.dz-scr__msg--them { background: #fff; border: 1px solid #e3e1dd; align-self: flex-start; }
.dz-scr__msg--me { background: #17181a; color: #fff; align-self: flex-end; }
.dz-scr__who {
display: block; font-size: .72em; font-weight: 680; letter-spacing: .06em;
text-transform: uppercase; opacity: .4; margin-bottom: .3em;
}
.dz-scr__cite {
display: inline-block; margin-top: .55em; padding: .26em .6em; border-radius: 999px;
background: #f1efeb; font-size: .78em; opacity: .62;
}
.dz-scr__row {
display: flex; align-items: center; gap: .7em; margin-top: auto;
padding: .62em .8em .62em 1em; background: #fff; border: 1px solid #e3e1dd;
border-radius: .95em;
}
.dz-scr__input { flex: 1; font-size: .84em; opacity: .38; }
.dz-scr__send { width: 1.6em; height: 1.6em; border-radius: 50%; background: #17181a; flex: none; }
`; Claude Code Instructions
CLI Install
npx innovations add deep-zoomWhere to use it
Use this when a business has ONE thing worth looking at closely — a material, a finish, a joint, a weld, a grain, a crop, a printed edge. The hero is an argument that the detail rewards attention, and it only works if it actually does. A page whose subject is a service, a team or a process has nothing at the bottom of the dive and should use a normal hero.
In Astro:
---
import DeepZoomHero from '../components/innovations/heroes/deep-zoom';
---
<DeepZoomHero client:load />
Needs client:load or client:visible. Without it you get the first plate, both pieces of copy stacked over it, and a page that is honest rather than broken — see the static-state note below.
WHAT THE REFERENCE ACTUALLY DOES, because it changes what you should expect. mercury.com's hero is a 1442x2023 section holding an 809px sticky stage, so the dive spends 1213px of scroll — 1.5 viewport heights — inside one pinned viewport. Those proportions are the default here. But their zoom is NOT css: the media element is 'hero-scrub-lg.mp4' (with -md and -sm cuts behind media queries, and a 3840x1440 still as the poster) and scroll drives currentTime. Their detail at the bottom of the dive exists because somebody rendered it. If you have the budget to render a video, scrub a video — it will always beat this. This component is what to do when you do not.
THE PLATE PYRAMID IS THE WHOLE IDEA. One photograph zoomed 18x is mush; three photographs zoomed about 3x each are sharp all the way down, because each hands over before it runs out of pixels. It is a mipmap chain walked backwards. Two rules make the hand-over invisible and both are easy to break:
1. ONE CURVE, OFFSET PER PLATE. Total zoom is the product of the per-plate zooms and every plate is drawn at exp(p * totalLog - itsOffset), which is exactly 1.0 the instant it takes over and keeps climbing on the same curve the outgoing plate is still climbing. Two plates cross-fading at different rates shear against each other and the eye catches it every time. Exponential rather than linear because a dolly-in at constant apparent speed IS exponential in scale — linear reads as slow, then a lunge.
2. PLATE i AT SCALE 1 MUST FRAME WHAT PLATE i-1 FRAMES AT FULL ZOOM. The cross-fade is not a cut between two views, it is one view arriving in focus. Get this wrong and the dive teleports.
SHOOTING OR GENERATING THE CHAIN. Decide the target first, then step in toward it: wide, closer, macro. You do not need the crops to register pixel-for-pixel, and it is worth knowing why — a stochastic surface (stone, soil, foliage, fabric, rust, crumb) has no structure to mismatch, so a plausible macro of the same material reads as focus resolving rather than as a cut. A surface with LAYOUT in it — a label, a face, a logo, a keyboard — will not forgive you, and for those you want the inset route below instead.
KEEP THE LAST PLATE'S ZOOM LOW. It is the one still on screen when the dive stops, so it is the one whose pixels run out in public. The bundled chain is 3.2, 3.0, 1.9 for 18x in total.
THE OUTGOING PLATE BLURS, AND THAT IS NOT A CHEAT. A lens pushed toward a surface throws everything off the focal plane soft, so the plate being handed off SHOULD go out of focus. That it also hides the upscaling and any mismatch between the two frames is a coincidence worth accepting.
DO NOT TURN OFF THE GRAIN. It looks like decoration and it is load-bearing: a fine noise field gives the eye a sharp, correctly-scaled detail to hold at every depth, so an upscaled plate underneath reads as film softness instead of as a low-resolution image. The deep end of the dive gets visibly cheaper the moment it is gone.
RESOLUTION IS THE REAL BUDGET. Each plate wants roughly its own zoom times the widest viewport it will be seen on — a plate zooming 3x on a 1600px screen wants about 4800px of source and will settle for less under the grain. This is the number to spend money on before anything else in this component.
THE STATIC STATE IS A REAL PAGE. Everything that moves lives under '.dz.is-motion', which the effect adds and nothing else does. No JS, a thrown error, or prefers-reduced-motion gives you the first plate with both pieces of copy stacked on it. Nothing is parked at opacity 0 waiting for a scroll handler that will never run.
WHEN THE TARGET IS A SCREEN, use 'heroes/deep-zoom-screen' rather than adding a fourth plate. It gives a plate an 'inset' rect and draws live DOM into it, glued to the photograph and re-laid-out each frame at real size — so the interface is pin-sharp at 5x because it is text rather than pixels, and there is no cross-fade to hide.
ONE HONEST LIMIT: this is one and a half viewport heights of scroll spent before the page begins. It buys attention on a page that has something to be attentive to. On a page that needs to state a price and a phone number, it is a toll.