heroes /
Photo Dock — Wipe (Editorial Monochrome / Fintech)
The variant where the photograph HOLDS STILL. The card
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Photo Dock — Wipe. The photograph HOLDS STILL and the page turns white
* around it.
*
* Sibling of `photo-dock`, and the difference is the whole point. There, the
* frame shrinks and the picture shrinks with it, so the subject gets smaller.
* Here nothing about the photograph moves — not its position, not its scale,
* not its crop. A card-shaped border firms up over it, and then white grows
* OUT from that border in four directions until it has covered everything
* else. What is left uncovered is the card.
*
* That is what the reference actually does, and the measurement says so: at the
* end of Revolut's transition the frame reads 228x322 while the <img> inside it
* still reads 1440x805. The image is not scaled down into the card; the card is
* a window onto an image that never moved.
*
* IT IS ONE SHAPE OFFSETTING OUTWARD, NOT FOUR PANELS SLIDING. The card's own
* rounded outline grows away from itself — every point on it moving the same
* distance `d` outward — so the white front stays a rounded rectangle the whole
* way and the screen corners are the last thing it reaches. That is a true
* parallel offset, which is why the radius grows with the distance: offsetting
* a rounded rect outward by d gives radius + d, exactly.
*
* An earlier build did this with four rectangular panels growing off the four
* edges. The coverage was right and the reading was wrong: straight fronts meet
* in a cross, the corners fill on a different schedule from the sides, and the
* whole thing looks like four things happening rather than one border leaving.
*
* Shrinking a hole instead — white appearing at the viewport edges and closing
* in — is a third effect again, and reads as the page eating the photograph
* rather than keeping it.
*
* ── THE STATIC STATE IS THE DEFAULT, AND IT IS A REAL PAGE.
* Every rule that moves or hides anything lives under `.pw.is-motion`, added by
* the effect below and nowhere else. With no JS, a thrown error, or
* prefers-reduced-motion this renders as a plain stack — nav, ink copy, the
* photograph with its card overlaid, then the dock copy and the supporting
* cards. The four wipe panels are display:none there; they are pure mechanism
* and have nothing to say when there is no scroll to drive them.
*/
export interface WipeCard {
/** Photograph. Pass null (or omit) for the built-in placeholder ground. */
src?: string | null;
alt?: string;
/** object-position, for crops whose subject is not dead centre. */
focus?: string;
label?: string;
value?: string;
pill?: string;
footer?: { title: string; meta?: string; amount?: string } | null;
}
export interface PhotoDockWipeHeroProps {
promo?: { text: string; link?: { label: string; href: string } } | null;
promoFrom?: string;
promoTo?: string;
brand?: string;
navLinks?: { label: string; href: string }[];
navSecondary?: { label: string; href: string } | null;
navCta?: { label: string; href: string } | null;
/** Scene A — white, over the photograph. */
headline?: string;
sub?: string;
cta?: { label: string; href: string } | null;
/** Scene B — ink, on the white the wipe lays down. */
dockHeadline?: string;
dockSub?: string;
dockCta?: { label: string; href: string } | null;
/** The photograph, and the card its centre becomes. */
card?: WipeCard;
/** The two that arrive either side of it. Give exactly two, or none. */
flanks?: WipeCard[];
dockW?: number;
dockH?: number;
dockRadius?: number;
gap?: number;
flankScale?: number;
dockBottom?: number;
dockVh?: number;
/** How far the photograph scales down across the wipe, as a fraction. It
* holds still in position — only the scale moves — so the surviving card
* quietly gains a little context as the white takes the rest. 0 is the pure
* hold. Keep it small: this variant's whole argument is that the picture
* does not travel, and past about 0.15 it stops reading as a settle and
* starts reading as the shrink that photo-dock already does properly. */
photoShrink?: number;
/** A SECOND shrink, run only after the white has filled the screen. Once
* nothing but the card is still showing the photograph, the picture no
* longer has to cover the stage — only the card — so it can afford to pull
* back much further than the settle during the wipe ever could. Total
* fraction, not additional: 0.4 means the photograph ends at 0.6 scale,
* having passed through photoShrink on the way. Set equal to photoShrink to
* turn the second phase off. */
photoShrinkEnd?: number;
/** How much the travelling outline's corner radius grows as it goes, as a
* fraction of the distance travelled. 1 is a TRUE parallel offset — the
* geometrically correct answer, and it turns the shape into a soft blob
* long before it leaves the screen, because the radius ends up bigger than
* the card ever was. 0 holds the card's radius and the shape stays a crisp
* rounded rectangle the whole way. The default keeps it reading as a card
* border rather than an expanding circle. */
cornerGrowth?: number;
/** Strength, 0 to 1, of the wash behind the white copy. A photograph with a
* bright top edge eats white type completely. 0 turns it off. */
scrim?: number;
ink?: string;
bone?: string;
ash?: string;
slate?: string;
}
const HAZES = [
{ a: "#8e8a85", b: "#adaaa5", c: "#cbc8c4" },
{ a: "#99958f", b: "#b6b3ae", c: "#d3d0cc" },
{ a: "#847f7a", b: "#a5a19c", c: "#c4c1bd" },
];
/** A placeholder ground in pure CSS, so the component ships with no assets. It
* is meant to be replaced: this hero's argument is a photograph being kept
* while everything around it is taken away, and a gradient is not worth
* keeping. Mid-tone rather than pale because the card's own type is white. */
function haze(i: number) {
const s = HAZES[i % HAZES.length];
return [
"radial-gradient(58% 36% at 22% 72%, rgba(255,255,255,.75), rgba(255,255,255,0) 70%)",
"radial-gradient(44% 28% at 78% 60%, rgba(255,255,255,.6), rgba(255,255,255,0) 72%)",
"radial-gradient(74% 42% at 50% 104%, rgba(255,255,255,.85), rgba(255,255,255,0) 66%)",
`linear-gradient(180deg, ${s.a} 0%, ${s.b} 52%, ${s.c} 100%)`,
].join(", ");
}
function Chrome({ card }: { card: WipeCard }) {
return (
<>
{(card.label || card.value || card.pill) && (
<div className={card.footer ? "pw__chrome pw__chrome--lifted" : "pw__chrome"}>
{card.label && <span className="pw__chrome-label">{card.label}</span>}
{card.value && <span className="pw__chrome-value">{card.value}</span>}
{card.pill && <span className="pw__chrome-pill">{card.pill}</span>}
</div>
)}
{card.footer && (
<div className="pw__strip">
<span className="pw__strip-dot" aria-hidden="true" />
<span className="pw__strip-text">
<strong>{card.footer.title}</strong>
{card.footer.meta && <em>{card.footer.meta}</em>}
</span>
{card.footer.amount && <span className="pw__strip-amount">{card.footer.amount}</span>}
</div>
)}
</>
);
}
export default function PhotoDockWipeHero({
promo = {
text: "Get a £50 bonus when a friend joins.",
link: { label: "See the offer", href: "#" },
},
promoFrom = "#1227fd",
promoTo = "#6fa0ff",
brand = "Northwind",
navLinks = [
{ label: "Personal", href: "#" },
{ label: "Business", href: "#" },
{ label: "Company", href: "#" },
],
navSecondary = { label: "Log in", href: "#" },
navCta = { label: "Sign up", href: "#" },
headline = "Banking & Beyond",
sub = "This is your bank, redefined. Get powerful daily banking and global freedom. Sign up for free in a tap.",
cta = { label: "Download the app", href: "#" },
dockHeadline = "Your salary, reimagined",
dockSub = "Spend smartly, send quickly, sort your salary automatically, and watch your savings grow.",
dockCta = { label: "Move your salary", href: "#" },
card = {
src: "/heroes/photo-dock/owner.webp",
alt: "A small-business owner checking an order sheet at her packing table",
label: "Personal",
value: "£6,012",
pill: "Accounts",
footer: { title: "Salary", meta: "Paid today", amount: "+£3,140" },
},
flanks = [
{
src: null,
label: "Personal · EUR",
value: "€3,126",
pill: "Accounts",
footer: { title: "Coffee in Paris", meta: "Yesterday, 09:02", amount: "-€3.25" },
},
{
src: null,
label: "Personal",
value: "£2,350",
pill: "Accounts",
footer: { title: "House bills", meta: "Due today", amount: "-£225" },
},
],
dockW = 300,
dockH = 424,
dockRadius = 22.5,
gap = 24,
flankScale = 0.92,
dockBottom = 40,
dockVh = 140,
scrim = 0.42,
cornerGrowth = 0.25,
photoShrink = 0.15,
photoShrinkEnd = 0.4,
ink = "#1f1f1f",
bone = "#ffffff",
ash = "#c9c9cd",
slate = "#717173",
}: PhotoDockWipeHeroProps) {
const rootRef = useRef<HTMLElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const stage = stageRef.current;
if (!root || !stage) return;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const copyA = root.querySelector<HTMLElement>(".pw__copy--a");
const copyB = root.querySelector<HTMLElement>(".pw__copy--b");
// WHERE THE CARD SITS IS MEASURED, NOT CHOSEN — and in this variant it is
// load-bearing twice, because the card is not somewhere the photograph
// travels to at the end, it is where the surviving rectangle sits from the
// very first frame. Get it wrong and the dock copy prints across it.
const COPY_B_TOP = 0.15;
const ROW_GAP = 28;
let navCover = 1;
const layout = () => {
const stageH = stage.clientHeight;
if (!stageH) return;
const top = stageH * COPY_B_TOP + (copyB?.offsetHeight ?? 0) + ROW_GAP;
const room = stageH - top - dockBottom;
const k = room < dockH ? Math.max(0.5, room / dockH) : 1;
// Both on the ROOT, not the stage: --dw/--dh are derived from --dock-k
// with var() and are substituted once, on .pw.
root.style.setProperty("--dock-top", `${Math.round(top)}px`);
root.style.setProperty("--dock-k", String(Math.round(k * 1000) / 1000));
// HOW FAR THE OUTLINE HAS TO TRAVEL depends on where the card sits, and
// the card sits low, so the distance to the top corners is much greater
// than to the bottom ones. Because the shape is a true offset — the card
// rect grown by d in every direction, corners included — a point is
// covered exactly when its distance TO THE RECT (not to its centre) is
// at most d. So the journey ends at the furthest screen corner.
const stageW = stage.clientWidth;
const cardW = dockW * k;
const cardH = dockH * k;
const l = (stageW - cardW) / 2;
const r = l + cardW;
const t2 = top;
const b2 = top + cardH;
const distToCard = (x: number, y: number) => {
const dx = Math.max(0, l - x, x - r);
const dy = Math.max(0, t2 - y, y - b2);
return Math.hypot(dx, dy);
};
const dmax =
Math.max(
distToCard(0, 0),
distToCard(stageW, 0),
distToCard(0, stageH),
distToCard(stageW, stageH)
) * 1.02;
// Computed for a TRUE offset, which is the tightest-cornered version of
// this shape and therefore the last to reach a corner. Any cornerGrowth
// below 1 squares the shape off and covers sooner, so this distance stays
// sufficient for every setting rather than needing to track the prop.
root.style.setProperty("--dmax", `${Math.ceil(dmax)}px`);
// HOW FAR THE PHOTOGRAPH MUST OVERHANG THE STAGE so that shrinking it
// never pulls an edge into view. A shrinking full-bleed picture opens a
// gap at every edge, and the wipe reaches the far edges LAST, so those
// gaps flash white before white is meant to be there.
//
// The amount is NOT 1/(1-shrink) — that would be right only if it scaled
// about the stage centre. It scales about the CARD's centre, which sits
// low, so the top edge is much further from the origin than the bottom
// and travels proportionally further. Size for the worst edge on each
// axis and keep the box centred, which leaves the resting crop centred
// too. Sized off the true final scale, so it is correct for any
// photoShrink rather than for the one it was tuned against.
const s1 = 1 - Math.min(0.9, Math.max(0, photoShrink));
const s2 = 1 - Math.min(0.9, Math.max(0, Math.max(photoShrink, photoShrinkEnd)));
const kk = 1 / s1 - 1;
const oy = top + cardH / 2;
// Phase one: cover the whole stage at the settle scale.
const stagePadY = Math.max(oy, stageH - oy) * kk * 1.02;
const stagePadX = (stageW / 2) * kk * 1.02;
// Phase two: the white has taken everything but the card, so only the
// card has to stay covered — but at a much smaller scale. Usually slack,
// and computed anyway so a big photoShrinkEnd cannot quietly pull the
// picture's edge inside the card and leave white showing in it.
const cardPadY = (cardH / s2 - stageH) / 2;
const cardPadX = (cardW / s2 - stageW) / 2;
const padY = Math.ceil(Math.max(stagePadY, cardPadY, 0));
const padX = Math.ceil(Math.max(stagePadX, cardPadX, 0));
root.style.setProperty("--photo-off-x", `${-padX}px`);
root.style.setProperty("--photo-off-y", `${-padY}px`);
root.style.setProperty("--photo-w", `${stageW + padX * 2}px`);
root.style.setProperty("--photo-h", `${stageH + padY * 2}px`);
// The ink nav twin is clipped in STAGE coordinates, but its own box
// starts below the promo bar. Hand it that offset so the clip lands.
const wrap = root.querySelector<HTMLElement>(".pw__navwrap");
root.style.setProperty("--nav-y", `${wrap ? wrap.offsetTop : 0}px`);
// Progress at which the shape has cleared the ENTIRE nav — its far
// corners, not the point above the card. Only the CTA pill uses this;
// everything else in the bar is handled per-pixel by the clip.
const navBottom = wrap ? wrap.offsetTop + wrap.offsetHeight : 100;
const reach = Math.max(distToCard(0, navBottom), distToCard(stageW, navBottom));
navCover = dmax > 0 ? Math.min(1, reach / dmax) : 1;
};
const travel = () => root.offsetHeight - window.innerHeight;
const progress = () => {
const t = travel();
const p = t > 0 ? -root.getBoundingClientRect().top / t : 0;
return p < 0 ? 0 : p > 1 ? 1 : p;
};
const seg = (p: number, a: number, b: number) => {
const v = (p - a) / (b - a);
return v < 0 ? 0 : v > 1 ? 1 : v;
};
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const tick = () => {
const p = progress();
const s = stage.style;
// FIVE BEATS, STRICTLY IN ORDER, and the order is the thing being
// described: the border firms up FIRST so the eye knows where the wipe
// will start from, the copy clears, THEN white grows out of that border,
// and only once it has finished do the siblings come out of hiding. The
// flanks starting before the wipe lands is the single worst thing you can
// do to this — they appear over a photograph that is still there.
s.setProperty("--e", String(seg(p, 0.02, 0.18))); // border solidifies
s.setProperty("--a", String(seg(p, 0.04, 0.26))); // scene A out
s.setProperty("--w", String(ease(seg(p, 0.18, 0.56)))); // the white, outward
s.setProperty("--b", String(seg(p, 0.52, 0.78))); // scene B in
s.setProperty("--f", String(ease(seg(p, 0.62, 0.94)))); // siblings out
// The second shrink, and it starts only once --w has finished. That is
// the whole licence for it: while the wipe is running the photograph
// still has to cover the parts of the stage the white has not reached, so
// it can only settle a little. After, the card is the only window left
// and the picture can pull right back.
s.setProperty("--z", String(ease(seg(p, 0.58, 0.9))));
root.classList.toggle("nav-onwhite", ease(seg(p, 0.18, 0.56)) >= navCover);
copyA?.classList.toggle("is-off", p > 0.2);
copyB?.classList.toggle("is-off", p < 0.62);
};
let queued = false;
const onResize = () => {
layout();
tick();
};
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
tick();
});
};
// is-motion goes on LAST, after the listeners are attached and one tick has
// run. If anything above throws, the class is never applied and the section
// stays the readable stack it renders as by default. Never move this up.
tick();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
root.classList.add("is-motion");
layout();
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
root.classList.remove("is-motion");
root.classList.remove("nav-onwhite");
root.style.removeProperty("--dock-top");
root.style.removeProperty("--dock-k");
root.style.removeProperty("--dmax");
root.style.removeProperty("--nav-y");
["--photo-off-x", "--photo-off-y", "--photo-w", "--photo-h"].forEach((v) =>
root.style.removeProperty(v)
);
copyA?.classList.remove("is-off");
copyB?.classList.remove("is-off");
};
}, [dockBottom, dockH, dockW, photoShrink, photoShrinkEnd]);
const vars = {
"--ink": ink,
"--bone": bone,
"--ash": ash,
"--slate": slate,
"--dock-w": `${dockW}px`,
"--dock-h": `${dockH}px`,
"--dock-r": `${dockRadius}px`,
"--gap": `${gap}px`,
"--flank": String(flankScale),
"--dock-bottom": `${dockBottom}px`,
"--dock-vh": `${dockVh}svh`,
"--scrim": String(scrim),
"--corner-growth": String(cornerGrowth),
"--photo-shrink": String(photoShrink),
"--photo-shrink-2": String(Math.max(photoShrink, photoShrinkEnd) - photoShrink),
"--promo-from": promoFrom,
"--promo-to": promoTo,
} as React.CSSProperties;
return (
<section className="pw" ref={rootRef} style={vars}>
<style>{CSS}</style>
<div className="pw__stage" ref={stageRef}>
{promo && (
<div className="pw__promo">
<span>{promo.text}</span>
{promo.link && (
<a href={promo.link.href} className="pw__promo-link">
{promo.link.label}
</a>
)}
</div>
)}
{/* TWO NAVS, ONE CLIPPED. The white one is correct over the
photograph, the ink one over the white — and during the wipe BOTH
grounds sit under the same bar at once, so there is no single colour
and no single moment to switch that is right for the whole of it.
The ink twin is clipped to exactly the growing shape, so every pixel
of the nav matches whatever is behind that pixel. */}
<div className="pw__navwrap">
<header className="pw__nav">
<span className="pw__brand">{brand}</span>
<nav className="pw__links">
{navLinks.map((l) => (
<a key={l.label} href={l.href}>
{l.label}
</a>
))}
</nav>
<span className="pw__navright">
{navSecondary && (
<a href={navSecondary.href} className="pw__navlink">
{navSecondary.label}
</a>
)}
{navCta && (
<a href={navCta.href} className="pw__navcta">
{navCta.label}
</a>
)}
</span>
</header>
<header className="pw__nav pw__nav--ink" aria-hidden="true">
<span className="pw__brand">{brand}</span>
<nav className="pw__links">
{navLinks.map((l) => (
<a key={l.label} href={l.href} tabIndex={-1}>
{l.label}
</a>
))}
</nav>
<span className="pw__navright">
{navSecondary && (
<a href={navSecondary.href} className="pw__navlink" tabIndex={-1}>
{navSecondary.label}
</a>
)}
{navCta && (
<a href={navCta.href} className="pw__navcta" tabIndex={-1}>
{navCta.label}
</a>
)}
</span>
</header>
</div>
<div className="pw__copy pw__copy--a">
<div className="pw__wrap">
<h1 className="pw__h1">{headline}</h1>
{sub && <p className="pw__sub">{sub}</p>}
{cta && (
<a href={cta.href} className="pw__pill">
{cta.label}
</a>
)}
</div>
</div>
<div className="pw__copy pw__copy--b">
<div className="pw__wrap pw__wrap--center">
<h2 className="pw__h2">{dockHeadline}</h2>
{dockSub && <p className="pw__sub pw__sub--ink">{dockSub}</p>}
{dockCta && (
<a href={dockCta.href} className="pw__pill">
{dockCta.label}
</a>
)}
</div>
</div>
<div className="pw__scene">
{/* The photograph. Nothing in the transition touches it — no
transform, no clip, no change of size. It is the fixed thing the
rest of the stage is taken away from. */}
<figure className="pw__shot">
<div className="pw__zoom">
{card.src ? (
<img
className="pw__photo"
src={card.src}
alt={card.alt ?? ""}
style={{ objectPosition: card.focus ?? "50% 50%" }}
/>
) : (
<div className="pw__photo pw__photo--haze" style={{ background: haze(0) }} aria-hidden="true" />
)}
</div>
<div className="pw__scrim" aria-hidden="true" />
{/* THE CARD'S OWN OUTLINE, TRAVELLING. This element starts life
exactly on the card — same size, same radius, same border — and
then offsets outward as one shape, carrying white behind it. The
hole inside it never moves, so what you see is a rounded ring of
white whose inner edge is the card and whose outer edge is the
border walking off the screen. */}
<div className="pw__grow" aria-hidden="true">
<div className="pw__grow-hole" />
</div>
<div className="pw__card">
<div className="pw__edge" aria-hidden="true" />
<Chrome card={card} />
</div>
</figure>
{flanks[0] && (
<figure className="pw__flank pw__flank--l" style={{ "--dir": "-1" } as React.CSSProperties}>
{flanks[0].src ? (
<img className="pw__photo" src={flanks[0].src} alt={flanks[0].alt ?? ""} />
) : (
<div className="pw__photo" style={{ background: haze(1) }} aria-hidden="true" />
)}
<Chrome card={flanks[0]} />
</figure>
)}
{flanks[1] && (
<figure className="pw__flank pw__flank--r" style={{ "--dir": "1" } as React.CSSProperties}>
{flanks[1].src ? (
<img className="pw__photo" src={flanks[1].src} alt={flanks[1].alt ?? ""} />
) : (
<div className="pw__photo" style={{ background: haze(2) }} aria-hidden="true" />
)}
<Chrome card={flanks[1]} />
</figure>
)}
</div>
</div>
</section>
);
}
const CSS = `
.pw {
--e: 0; --a: 0; --w: 0; --b: 0; --f: 0; --z: 0;
--display: 'Aeonik Pro', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--ui: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--dock-k: 1;
--dw: calc(var(--dock-w) * var(--dock-k));
--dh: calc(var(--dock-h) * var(--dock-k));
--fw: calc(var(--dw) * var(--flank));
--off: calc((var(--dw) + var(--fw)) / 2 + var(--gap));
/* Overwritten in px by the effect's layout pass. */
--dock-top: 50%;
/* How far the outline must travel to clear the furthest screen corner.
Measured in the layout pass — it depends on where the card sits. */
--dmax: 1200px;
--nav-y: 0px;
position: relative;
background: var(--bone);
color: var(--ink);
font-family: var(--ui);
-webkit-font-smoothing: antialiased;
}
.pw *, .pw *::before, .pw *::after { box-sizing: border-box; }
.pw a { text-decoration: none; }
.pw__links a, .pw__navlink { color: inherit; }
.pw__stage {
position: relative;
/* HOW FAR THE OUTLINE HAS TRAVELLED, and it is declared HERE rather than on
.pw because --w is written by JS onto this element. A var() derived on an
ancestor is substituted using that ancestor's values, so deriving --dpx up
on .pw resolves it against --w: 0 forever and hands every descendant a
frozen zero. Read by the shape and by the nav's clip, which is why it
cannot just live on .pw__grow either. */
--dpx: calc(var(--dmax) * var(--w));
}
.pw.is-motion { height: calc(100vh + var(--dock-vh)); height: calc(100svh + var(--dock-vh)); }
.pw.is-motion .pw__stage {
position: sticky; top: 0;
height: 100vh; height: 100svh;
overflow: hidden;
}
.pw__promo {
position: relative; z-index: 4;
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 40px; padding: 8px 24px;
background: linear-gradient(to right, var(--promo-from), var(--promo-to));
color: #fff; font-size: 16px; line-height: 1.2; text-align: center;
}
.pw__promo-link { text-decoration: underline; }
/* Ink is the DEFAULT; white is what the photograph earns, and only the animated
state has a photograph under the nav. The interpolation rides --w because the
ground under the nav is exactly what the wipe is replacing. */
.pw__navwrap { position: relative; z-index: 3; }
.pw__nav {
display: flex; align-items: center; gap: 32px;
max-width: 1200px; margin: 0 auto; padding: 20px 24px;
color: var(--ink);
}
/* White is what the photograph earns; ink is the default, which is what the
no-JS stack gets. */
.pw.is-motion .pw__nav { color: #fff; }
/* THE INK TWIN. Sits exactly on top of the white one and is revealed only
where the growing shape has already put white behind the bar — the clip is
the same rectangle, same radius, same maths as .pw__grow, expressed as
insets from this layer's own box.
Why a twin at all: during the wipe the nav has photograph under one end and
white under the other AT THE SAME TIME. Every single-colour answer fails
somewhere, and every "flip at moment X" answer fails either the middle
(white on white, which is what shipped for a build) or the ends (ink on a
dark photograph). Two layers and a clip is the only version with no wrong
pixel in it, and it costs one duplicated header.
--nav-y is this layer's own offset inside the stage, written by the layout
pass, because the clip is expressed in stage coordinates and this box does
not start at the top of the stage — the promo bar is above it. */
.pw__nav--ink { display: none; }
.pw.is-motion .pw__nav--ink {
display: flex;
position: absolute; inset: 0 0 auto 0;
max-width: none; padding-left: 0; padding-right: 0;
color: var(--ink);
pointer-events: none;
clip-path: inset(
max(0px, calc(var(--dock-top) - var(--dpx) - var(--nav-y)))
max(0px, calc(50% - var(--dw) / 2 - var(--dpx)))
0px
max(0px, calc(50% - var(--dw) / 2 - var(--dpx)))
round calc(var(--dock-r) + var(--dpx) * var(--corner-growth))
);
}
/* The twin's inner row has to line up with the original to the pixel, so it
keeps the same max-width and padding — just applied inside, since the layer
itself has to span the full stage for the clip percentages to mean what they
mean. */
.pw.is-motion .pw__nav--ink > * { position: relative; }
.pw.is-motion .pw__nav--ink {
--pad: max(24px, calc((100% - 1200px) / 2 + 24px));
padding-left: var(--pad); padding-right: var(--pad);
}
/* THE PILL DOES NOT SPLIT. Per-pixel clipping is the right answer for plain
text — a word half white and half ink reads as the wipe passing through it —
but a FILLED button straddling the same edge reads as a rendering glitch,
half white pill and half dark pill. A pill carries its own background and is
therefore legible over either ground, so it simply does not need to change
while the edge is crossing it. It swaps once, discretely, when the shape has
cleared the whole bar. That moment is computed, not guessed: it is when the
travel distance passes the furthest of the nav's two bottom corners. */
.pw.is-motion.nav-onwhite .pw__navcta { background: var(--ink); color: #fff; }
.pw__brand { font-family: var(--display); font-size: 24px; font-weight: 500; letter-spacing: -0.024em; }
.pw__links { display: flex; gap: 32px; font-size: 16px; }
.pw__navright { margin-left: auto; display: flex; align-items: center; gap: 16px; font-size: 16px; }
.pw__navcta {
padding: 10px 24px; border-radius: 9999px; font-weight: 600;
background: var(--ink); color: #fff;
}
.pw.is-motion .pw__navcta { background: #fff; color: var(--ink); }
@media (max-width: 780px) { .pw__links, .pw__navlink { display: none; } }
.pw__copy { position: relative; z-index: 2; padding: 40px 0 56px; }
.pw__wrap { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.pw__wrap--center { text-align: center; }
.pw__copy--a { color: var(--ink); }
.pw.is-motion .pw__copy--a { color: #fff; }
.pw__copy--b { color: var(--ink); }
.pw__h1 {
margin: 0; font-family: var(--display); font-weight: 500;
font-size: clamp(44px, 6.1vw, 88px); line-height: 1; letter-spacing: -0.024em;
}
.pw__h2 {
margin: 0; font-family: var(--display); font-weight: 500;
font-size: clamp(32px, 3.6vw, 52px); line-height: 1; letter-spacing: -0.012em;
}
.pw__sub {
margin: 24px 0 0; max-width: 34ch; font-family: var(--display); font-weight: 400;
font-size: 18px; line-height: 1.33;
}
.pw__wrap--center .pw__sub { max-width: 62ch; margin-left: auto; margin-right: auto; }
.pw__sub--ink { color: var(--slate); }
.pw__pill {
display: inline-flex; align-items: center; margin-top: 32px;
min-height: 44px; padding: 0 24px; border-radius: 9999px;
background: var(--ink); color: #fff; font-size: 16px; font-weight: 600;
}
/* ── The scene. Static: a wrapping flex row. Motion: absolute, and z-index 1
puts the whole thing UNDER the copy while opening a stacking context so
the wipe/flank/card order below stays contained. ────────────────────── */
.pw__scene {
display: flex; flex-wrap: wrap; justify-content: center; align-items: flex-start;
gap: var(--gap); padding: 0 24px 80px;
}
.pw.is-motion .pw__scene { position: absolute; inset: 0; display: block; padding: 0; z-index: 1; }
.pw__shot, .pw__flank { position: relative; margin: 0; background: #b6b3ae; }
.pw__shot { width: 100%; height: 70vh; height: 70svh; }
.pw__flank { width: var(--fw); height: calc(var(--dh) * var(--flank)); border-radius: var(--dock-r); overflow: hidden; }
/* NO z-index on the shot. It is position:absolute at z-auto, so it opens no
stacking context and the wipes, flanks and card inside and beside it order
against each other freely. Give it one and the flanks can no longer sit
between the wipe panels and the card. */
.pw.is-motion .pw__shot { position: absolute; inset: 0; width: auto; height: auto; }
.pw__photo { display: block; width: 100%; height: 100%; object-fit: cover; }
/* The scale acts on a wrapper rather than the img so the origin can be given in
STAGE coordinates — the card's centre — which is what makes the card's own
view shrink in place instead of drifting while it does it. The scrim stays
outside this wrapper: it is aligned to the copy, not to the picture. */
.pw__zoom { position: relative; width: 100%; height: 100%; }
.pw.is-motion .pw__zoom {
position: absolute; inset: 0;
transform-origin: 50% calc(var(--dock-top) + var(--dh) / 2);
transform: scale(
calc(1 - var(--photo-shrink) * var(--w) - var(--photo-shrink-2) * var(--z))
);
}
.pw.is-motion .pw__zoom .pw__photo {
position: absolute;
left: var(--photo-off-x, 0px); top: var(--photo-off-y, 0px);
width: var(--photo-w, 100%); height: var(--photo-h, 100%);
/* max-width: none IS LOAD-BEARING. A host stylesheet's img { max-width:100% }
— near-universal in resets, and present in this lab — silently clamps the
oversize back to stage width while leaving the HEIGHT oversized, so the
photograph ends up 100% wide and 111% tall. The edges it was oversized to
protect are then exposed on the left and right the moment it shrinks. It
measures as a 77px gap and reads as a white sliver down one side. */
max-width: none; max-height: none;
}
.pw__scrim {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(
180deg,
rgba(0, 0, 0, var(--scrim)) 0%,
rgba(0, 0, 0, calc(var(--scrim) * 0.5)) 32%,
rgba(0, 0, 0, 0) 62%
);
}
/* Gone by the time the wipe lands, so the surviving card is the clean picture
and not a darkened corner of one. */
.pw.is-motion .pw__scrim { opacity: calc(1 - var(--w)); }
.pw__flank .pw__scrim { display: none; }
/* ── THE WIPE. One shape: the card's outline offsetting outward by --dpx,
with white filling the space it has vacated.
Construction, and each piece is load-bearing:
· .pw__grow is the offset outline. Width and height grow by 2*--dpx and
the radius by exactly --dpx, which is what makes it a true parallel
offset of the card rather than a rectangle that merely gets bigger.
Its border IS the card's border, walking away.
· .pw__grow-hole sits at the card's exact size and position and floods
white everywhere OUTSIDE itself with a 100vmax box-shadow spread —
the same trick the arc/blob family uses for its curved band edge.
· overflow:hidden on the parent clips that flood to the offset outline.
White between the two, photograph beyond it, and the hole never moves. */
.pw__grow { display: none; }
.pw.is-motion .pw__grow {
display: block;
position: absolute; z-index: 1;
left: calc(50% - var(--dw) / 2 - var(--dpx));
top: calc(var(--dock-top) - var(--dpx));
width: calc(var(--dw) + 2 * var(--dpx));
height: calc(var(--dh) + 2 * var(--dpx));
border-radius: calc(var(--dock-r) + var(--dpx) * var(--corner-growth));
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--ash) calc(var(--w) * 100%), #fff);
opacity: calc(0.22 + 0.78 * var(--e));
}
.pw__grow-hole {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: var(--dw); height: var(--dh); border-radius: var(--dock-r);
box-shadow: 0 0 0 100vmax var(--bone);
}
/* ── The card. Same rectangle from the first frame to the last — it does not
travel, it is simply what survives. ─────────────────────────────────── */
.pw__card {
position: absolute; left: 50%; transform: translateX(-50%);
top: var(--dock-top); width: var(--dw); height: var(--dh);
border-radius: var(--dock-r);
}
.pw.is-motion .pw__card { z-index: 3; }
.pw__edge {
position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
border: 1px solid var(--ash);
opacity: 0.3;
}
/* The card's own resting hairline only has a job once the travelling outline
has left. Before that it would be a second border sitting under the first. */
.pw.is-motion .pw__edge { opacity: var(--w); }
/* OUT FROM BEHIND THE CARD — AND THE Z-INDEX DOES NOT DO IT. There is no card
layer to hide behind: the "card" is a HOLE in the white, so the photograph
showing through it is painted at the very bottom of the stack. A flank given
a lower z-index than .pw__card is still above the photograph, so at rest it
sat squarely on top of the picture in the middle of the stage. The opacity
fade this replaces was covering for exactly that, and fading in beside the
card is the opposite of emerging from behind it.
So each flank is clipped on the edge that faces the card: hidden wherever it
overlaps the card's column, revealed as it slides clear. Both terms are the
same ones that drive the slide, so the clip cannot drift out of step with the
movement. At --f 0 the inset exceeds the flank's own width and it is entirely
gone; at 1 it goes negative, clamps at 0, and the whole card is out.
No opacity anywhere: the reveal is positional, which is what makes it read as
a solid object coming out from behind another one. */
.pw.is-motion .pw__flank {
position: absolute; z-index: 2;
top: calc(var(--dock-top) + (var(--dh) - var(--dh) * var(--flank)) / 2);
left: 50%;
width: var(--fw);
height: calc(var(--dh) * var(--flank));
transform: translateX(calc(-50% + var(--off) * var(--f) * var(--dir)));
}
.pw.is-motion .pw__flank--l {
clip-path: inset(0 max(0px, calc(var(--fw) / 2 + var(--dw) / 2 - var(--off) * var(--f))) 0 0);
}
.pw.is-motion .pw__flank--r {
clip-path: inset(0 0 0 max(0px, calc(var(--fw) / 2 + var(--dw) / 2 - var(--off) * var(--f))));
}
/* ── Card contents. The card is card-sized from the start here, so unlike the
shrinking variant the chrome never rescales with the transition — only
with --dock-k, when a short viewport forces the card down. ─────────── */
.pw__card, .pw__flank { --cs: calc(var(--dock-k) * 0.66); --ss: max(0.68, var(--dock-k)); }
.pw__flank { --cs: calc(var(--dock-k) * var(--flank) * 0.66); --ss: max(0.68, calc(var(--dock-k) * var(--flank))); }
.pw__chrome {
position: absolute; left: 50%; top: 50%;
width: 336px; height: 475px; max-width: 92%; max-height: 88%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
color: #fff; text-align: center;
transform: translate(-50%, calc(-50% - var(--lift, 0px))) scale(var(--cs));
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 2px 22px rgba(0, 0, 0, 0.3);
}
.pw__chrome--lifted { --lift: calc(26px * var(--ss)); }
.pw__chrome-label { font-size: 16px; }
.pw__chrome-value { font-family: var(--display); font-weight: 500; font-size: 52px; line-height: 1; letter-spacing: -0.012em; }
.pw__chrome-pill {
margin-top: 4px; padding: 8px 20px; border-radius: 9999px;
background: #fff; color: var(--ink); font-size: 14px; font-weight: 600; text-shadow: none;
}
.pw__strip {
position: absolute;
left: calc(10px * var(--ss)); right: calc(10px * var(--ss)); bottom: calc(10px * var(--ss));
display: flex; align-items: center; gap: calc(10px * var(--ss));
padding: calc(10px * var(--ss)) calc(12px * var(--ss));
border-radius: calc(16px * var(--ss)); background: #fff; color: var(--ink);
}
.pw.is-motion .pw__strip { opacity: var(--w); }
.pw__strip-dot {
width: calc(28px * var(--ss)); height: calc(28px * var(--ss));
flex: none; border-radius: 9999px; background: var(--promo-from);
}
.pw__strip-text { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; text-align: left; }
.pw__strip-text strong, .pw__strip-text em, .pw__strip-amount {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pw__strip-text strong { font-size: calc(14px * var(--ss)); font-weight: 600; }
.pw__strip-text em { font-style: normal; font-size: calc(12px * var(--ss)); color: var(--slate); }
.pw__strip-amount { margin-left: auto; flex: none; font-size: calc(14px * var(--ss)); font-weight: 600; }
.pw.is-motion .pw__copy { position: absolute; left: 0; right: 0; padding: 0; }
.pw.is-motion .pw__copy--a {
top: 22%;
opacity: calc(1 - var(--a));
transform: translate3d(0, calc(var(--a) * -56px), 0);
}
.pw.is-motion .pw__copy--b {
/* 15% here and COPY_B_TOP in the effect are the same number. */
top: 15%;
opacity: var(--b);
transform: translate3d(0, calc((1 - var(--b)) * 24px), 0);
}
.pw.is-motion .pw__copy.is-off { pointer-events: none; }
@media (max-width: 780px) {
.pw__shot { height: 62svh; }
.pw__chrome-value { font-size: 40px; }
.pw.is-motion .pw__copy--a { top: 26%; }
}
@media (prefers-reduced-motion: reduce) {
.pw, .pw * { animation: none !important; transition: none !important; }
}
`; Claude Code Instructions
CLI Install
npx innovations add photo-dock-wipeWhere to use it
Use this wherever you would use 'photo-dock', and choose between them on ONE question: should the subject stay the size it is?
· photo-dock SHRINKS the picture into the card. The subject gets smaller. Good when the photograph is atmosphere — a sky, a landscape, a texture — and losing scale costs nothing.
· photo-dock-wipe KEEPS the picture and takes the page away from around it. The subject never changes size, position or crop. Good when the photograph has a face in it, or any detail that stops working small.
In Astro:
---
import PhotoDockWipeHero from '../components/innovations/heroes/photo-dock-wipe';
---
<PhotoDockWipeHero client:load />
Needs client:load or client:visible. Server-rendered alone you get the static stack, which is a real page.
IT IS ONE SHAPE OFFSETTING OUTWARD, AND THAT IS THE WHOLE COMPONENT. The card's own rounded outline moves away from itself, every point travelling the same distance, so the white front stays a rounded rectangle the entire way and the screen corners are the last thing it reaches. Built as: an element that is the card grown by --dpx on every side, holding a child at the card's exact size that floods white OUTSIDE itself with a 100vmax box-shadow spread, with overflow:hidden on the parent clipping that flood to the outline. White between the two, photograph beyond, hole never moves.
Two wrong constructions, both tried:
· FOUR PANELS growing off the four edges. Coverage is right and the reading is wrong — straight fronts meet in a cross, corners fill on a different schedule from the sides, and it looks like four things happening rather than one border leaving.
· A SHRINKING HOLE in a white sheet. That makes white arrive at the viewport edges and close in, which reads as the page eating the photograph rather than keeping it.
'photoShrink' (default 0.15) lets the picture settle down a little as the white takes the rest, so the surviving card quietly gains some context. Keep it modest — this variant's argument is that the photograph does not travel, and much past 0.15 it stops reading as a settle and starts doing badly what photo-dock does properly. It scales about the CARD's centre, so nothing drifts inside the card while it happens.
'photoShrinkEnd' (default 0.4) is a SECOND shrink that runs only after the white has filled the screen, and the sequencing is what makes it affordable. While the wipe is still running the photograph has to cover every part of the stage the white has not reached yet, so it can only settle a little. Once nothing but the card is still showing the picture, the coverage requirement collapses to the card alone and it can pull right back — 0.4 means it ends at 0.6 scale, having passed through photoShrink on the way. Set it equal to photoShrink to switch the second phase off.
The overhang that makes both of those safe is computed at runtime and must stay that way. A shrinking full-bleed picture opens a gap at every edge, and the wipe reaches the far edges last, so the gaps flash white before white belongs there. The needed overhang is NOT 1/(1-shrink): that is only right for a scale about the stage centre, and this one scales about the card, which sits low — the top edge is much further from the origin and travels proportionally further. It is sized for the worst edge on each axis from the measured origin, and for BOTH phases: cover the stage at the settle scale, cover the card at the final one. The second is usually slack and is computed anyway, so a large photoShrinkEnd cannot quietly pull the picture's edge inside the card and leave white showing in it. Two related traps: a host reset's img { max-width: 100% } silently clamps the oversize on the horizontal axis only, leaving the picture 100% wide and 111% tall, so the rule carries max-width: none; and the box is kept centred on the stage so the resting crop stays centred.
'cornerGrowth' is the other taste knob. A true parallel offset grows the radius by the full distance travelled, which is geometrically correct and turns the shape into a soft blob long before it leaves — at the end the radius is bigger than the card ever was. The default of 0.25 keeps it reading as a card border rather than an expanding circle. 0 holds the card's own radius all the way. Note the travel distance is computed for a true offset, the tightest-cornered and therefore slowest-covering version, so it stays sufficient at every setting.
FIVE BEATS, STRICTLY ORDERED, and this order is the description of the effect rather than a set of numbers that happen not to collide:
· --e 0.02 to 0.18, the border firms up, so the eye knows where the white will come from before it comes
· --a 0.04 to 0.26, the white copy leaves
· --w 0.18 to 0.56, eased, the wipe
· --b 0.52 to 0.78, the dock copy arrives on white that is already white
· --f 0.62 to 0.94, the siblings come out from behind the card
THE SIDE CARDS EMERGE FROM BEHIND THE CENTRE ONE, AND Z-INDEX DOES NOT DO IT. There is no card layer to hide behind — the card is a HOLE in the white, so the photograph showing through it is painted at the bottom of the stack, and a flank given a lower z-index than the card still sits on top of the picture. The first build papered over that with an opacity fade, which reads as two cards appearing beside the card rather than coming out from under it. Each flank is instead clipped on the edge that faces the card, using the same terms that drive its slide, so it is hidden wherever it overlaps the card's column and revealed as it clears. No opacity anywhere: the reveal is positional, which is what makes it read as a solid object.
THE NAV IS TWO NAVS, ONE CLIPPED, and this is worth copying wherever a full-bleed hero has a transparent header. During the wipe the bar has photograph under one end and white under the other at the same time, so no single colour and no single moment to switch is right for all of it. Timing was tried both ways and both fail: flip on the near point and the ends go ink-on-photograph, flip on the far corners and the middle sits white-on-white for half the transition. The fix is a duplicate header in ink, clipped to exactly the growing shape, so every pixel matches its own backdrop — you watch the wordmark change colour letter by letter as the edge crosses it.
The filled CTA pill is the exception and does NOT split. Per-pixel clipping reads as a wipe on plain text and as a rendering glitch on a filled button, half white pill and half dark pill. A pill carries its own background and is legible over either ground, so it stays put and swaps once, when the shape has cleared the whole bar.
The beat that matters most is --f starting after --w finishes. Siblings appearing while the photograph is still full-bleed is the worst thing you can do to this — two cards floating on a picture, with no white to be cards on.
THE CARD DOES NOT TRAVEL. Unlike the shrinking variant, the surviving rectangle sits at its final position from the very first frame; it is simply unmarked until the border comes up. So its placement is not the end of a journey, it is a composition decision made at rest — which is why the measured layout pass matters here even more. It reads the dock copy's height on mount and resize and puts the card underneath it, shrinking the card (--dock-k) if a short viewport cannot hold both.
THE SUBJECT MUST BE INSIDE THE RECTANGLE, AND THE RECTANGLE SITS LOW. This is the constraint that replaces photo-dock's 'must survive the crop', and it is stricter than it sounds. Whatever falls inside that card-shaped region is the entire thing you keep; everything else in the photograph is thrown away. And the region is NOT in the middle of the frame — the dock copy owns the upper third, so the card lands in the lower-middle, typically spanning about 43% to 95% of the stage height.
So the subject has to be LOW-CENTRE in the photograph, not centred. The demo picture is a worked example of getting this wrong: a small-business owner whose face sits in the top third, so the surviving card keeps her hands, her torso and the parcel she is wrapping, and her face is wiped away with everything else. It still reads — hands at work is a fine card — but it is not the picture anyone would have chosen.
Check it before you commit to a photograph: find the point 70% down the frame, and whatever is there is your card. 'focus' shifts the crop horizontally and will help a subject that is off to one side, but it cannot rescue a face in the top third — there is no vertical slack in a cover-fitted 16:9 image inside a 16:9 stage. Either pick a photograph framed for it, re-crop the one you have to put the subject low, or use 'photo-dock' instead, which shrinks the whole picture and therefore keeps all of it.
COLOUR IS THE ONE RULE THIS SYSTEM WILL NOT BEND ON. Seven neutrals and a single blue gradient, and the gradient belongs to the promo bar, once per page. The nav interpolates white to ink on --w because the ground under it genuinely changes. The card's border interpolates white to an ash hairline for the same reason: a white border on white is not a border, and the system separates surfaces with #c9c9cd hairlines rather than shadows.
Type: display face at weight 500 and never heavier, tracking tightening as it grows. Controls are pills at 9999px. No shadows on anything.
The 'scrim' prop exists because white type over a bright photograph is invisible — measured, not guessed. It fades out with the wipe so the surviving card is a clean picture rather than a darkened corner of one. Pass scrim={0} for a photograph dark enough not to need it.