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:
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user