Scale up hero graphics and fade them into the text

- Stage/SceneCanvas: per-breakpoint framing (zoom + horizontal shift)
- Desktop: graphic spans 72% of the hero, zoomed ~1.35-1.4x and shifted
  right, masked to fade toward the text column and at top/bottom edges
- Mobile: full-bleed, taller, zoomed ~1.3x; lower part slides under the
  text and fades out downward

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-03 12:09:27 +01:00
co-authored by Claude Opus 5.5
parent d93550ed29
commit 3ceeca1be0
5 changed files with 97 additions and 9 deletions
+10 -4
View File
@@ -40,14 +40,20 @@ export default function PageHero({
>
<div
className={twMerge(
"relative w-full h-[38svh] min-h-[260px] max-h-[440px] mb-4",
// mobile: full-bleed, taller, lower part slides under the text
"relative h-[46svh] min-h-[300px] max-h-[540px] -mb-[10svh]",
"-mx-4 w-[calc(100%+2rem)] sm:-mx-10 sm:w-[calc(100%+5rem)] xl:mx-0",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
"xl:[mask-image:linear-gradient(to_right,transparent,black_18%)]",
"xl:w-[72%] xl:h-auto xl:max-h-none xl:mb-0 hero-graphic",
)}
data-parallax="0.4"
>
<SceneCanvas scene={scene} className="w-full h-full" />
<SceneCanvas
scene={scene}
className="w-full h-full"
desktop={{ zoom: 1.4, shiftX: 0.12 }}
mobile={{ zoom: 1.35 }}
/>
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw] gap-6">
{eyebrow && (
+25 -1
View File
@@ -16,12 +16,28 @@ const SCENES = {
export type SceneName = keyof typeof SCENES;
export type SceneLayout = { zoom?: number; shiftX?: number };
type Props = {
scene: SceneName;
className?: string;
/** Framing at xl (≥1280px) and up. */
desktop?: SceneLayout;
/** Framing below xl. */
mobile?: SceneLayout;
};
export default function SceneCanvas({ scene, className }: Props) {
const DESKTOP_QUERY = "(min-width: 1280px)";
export default function SceneCanvas({
scene,
className,
desktop,
mobile,
}: Props) {
// read through a ref so the stage always sees the latest props
const layoutRef = React.useRef({ desktop, mobile });
layoutRef.current = { desktop, mobile };
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [ready, setReady] = React.useState(false);
@@ -45,6 +61,14 @@ export default function SceneCanvas({ scene, className }: Props) {
canvas,
container,
factory: mod.default,
layout: () => {
const l = layoutRef.current;
return (
(window.matchMedia(DESKTOP_QUERY).matches
? l.desktop
: l.mobile) ?? {}
);
},
onReady: () => setReady(true),
});
if (disposed) handle.dispose();
+13 -1
View File
@@ -37,12 +37,20 @@ export type SceneFactory = (
stage: Stage,
) => SceneInstance | Promise<SceneInstance>;
export type StageLayout = { zoom?: number; shiftX?: number };
export type StageOptions = {
canvas: HTMLCanvasElement;
container: HTMLElement;
factory: SceneFactory;
entryDuration?: number;
entryDelay?: number;
/**
* Framing per breakpoint. `zoom` > 1 scales the scene up past its frame
* (it may crop); `shiftX` moves the scene horizontally as a fraction of
* the canvas width (+ = right). Re-evaluated on every resize.
*/
layout?: () => StageLayout;
onReady?: () => void;
};
@@ -117,8 +125,12 @@ export async function createStage(opts: StageOptions) {
const tan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
const zForHeight = instance.frame.height / 2 / tan;
const zForWidth = instance.frame.width / 2 / (tan * camera.aspect);
camera.position.set(0, 0, Math.max(zForHeight, zForWidth));
const { zoom = 1, shiftX = 0 } = opts.layout?.() ?? {};
camera.position.set(0, 0, Math.max(zForHeight, zForWidth) / zoom);
camera.lookAt(0, 0, 0);
// shift the rendered window so the subject sits off-centre
if (shiftX) camera.setViewOffset(w, h, -shiftX * w, 0, w, h);
else camera.clearViewOffset();
camera.updateProjectionMatrix();
if (reducedMotion) renderFrame(0, 0);
}
+10 -3
View File
@@ -20,12 +20,19 @@ export default function Main() {
>
<div
className={[
"relative w-full h-[40svh] min-h-[280px] max-h-[520px] mb-4",
// mobile: full-bleed, taller, lower part slides under the text
"relative h-[54svh] min-h-[340px] max-h-[620px] -mb-[12svh]",
"-mx-4 w-[calc(100%+2rem)] sm:-mx-10 sm:w-[calc(100%+5rem)] xl:mx-0",
"xl:absolute xl:inset-y-0 xl:right-0 xl:left-auto",
"xl:w-1/2 xl:h-auto xl:max-h-none xl:mb-0",
"xl:w-[72%] xl:h-auto xl:max-h-none xl:mb-0 hero-graphic",
].join(" ")}
>
<SceneCanvas scene="face" className="w-full h-full" />
<SceneCanvas
scene="face"
className="w-full h-full"
desktop={{ zoom: 1.35, shiftX: 0.12 }}
mobile={{ zoom: 1.3 }}
/>
</div>
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw]">
<Span data-reveal="fade">Benjamin Toby</Span>
+39
View File
@@ -114,3 +114,42 @@ html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
/* Hero graphics (<xl): stacked above the text; fades out downward into it */
@media (max-width: 79.999rem) {
.hero-graphic {
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 10%,
#000 52%,
rgb(0 0 0 / 0.35) 72%,
rgb(0 0 0 / 0.1) 86%,
transparent 100%
);
}
}
/* Hero graphics (xl+): wide canvas that fades out toward the text column */
@media (min-width: 80rem) {
.hero-graphic {
/* horizontal: fade toward the text; vertical: soften the crop at
the header and section edges. Intersected so both apply. */
mask-image:
linear-gradient(
to right,
transparent 0%,
rgb(0 0 0 / 0.15) 22%,
rgb(0 0 0 / 0.55) 40%,
#000 58%
),
linear-gradient(
to bottom,
transparent 0%,
#000 12%,
#000 82%,
transparent 100%
);
mask-composite: intersect;
}
}