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
|
<div
|
||||||
className={twMerge(
|
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: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",
|
||||||
"xl:[mask-image:linear-gradient(to_right,transparent,black_18%)]",
|
|
||||||
)}
|
)}
|
||||||
data-parallax="0.4"
|
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>
|
</div>
|
||||||
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw] gap-6">
|
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw] gap-6">
|
||||||
{eyebrow && (
|
{eyebrow && (
|
||||||
|
|||||||
@@ -16,12 +16,28 @@ const SCENES = {
|
|||||||
|
|
||||||
export type SceneName = keyof typeof SCENES;
|
export type SceneName = keyof typeof SCENES;
|
||||||
|
|
||||||
|
export type SceneLayout = { zoom?: number; shiftX?: number };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scene: SceneName;
|
scene: SceneName;
|
||||||
className?: string;
|
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 containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
@@ -45,6 +61,14 @@ export default function SceneCanvas({ scene, className }: Props) {
|
|||||||
canvas,
|
canvas,
|
||||||
container,
|
container,
|
||||||
factory: mod.default,
|
factory: mod.default,
|
||||||
|
layout: () => {
|
||||||
|
const l = layoutRef.current;
|
||||||
|
return (
|
||||||
|
(window.matchMedia(DESKTOP_QUERY).matches
|
||||||
|
? l.desktop
|
||||||
|
: l.mobile) ?? {}
|
||||||
|
);
|
||||||
|
},
|
||||||
onReady: () => setReady(true),
|
onReady: () => setReady(true),
|
||||||
});
|
});
|
||||||
if (disposed) handle.dispose();
|
if (disposed) handle.dispose();
|
||||||
|
|||||||
@@ -37,12 +37,20 @@ export type SceneFactory = (
|
|||||||
stage: Stage,
|
stage: Stage,
|
||||||
) => SceneInstance | Promise<SceneInstance>;
|
) => SceneInstance | Promise<SceneInstance>;
|
||||||
|
|
||||||
|
export type StageLayout = { zoom?: number; shiftX?: number };
|
||||||
|
|
||||||
export type StageOptions = {
|
export type StageOptions = {
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
factory: SceneFactory;
|
factory: SceneFactory;
|
||||||
entryDuration?: number;
|
entryDuration?: number;
|
||||||
entryDelay?: 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;
|
onReady?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,8 +125,12 @@ export async function createStage(opts: StageOptions) {
|
|||||||
const tan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
|
const tan = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
|
||||||
const zForHeight = instance.frame.height / 2 / tan;
|
const zForHeight = instance.frame.height / 2 / tan;
|
||||||
const zForWidth = instance.frame.width / 2 / (tan * camera.aspect);
|
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);
|
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();
|
camera.updateProjectionMatrix();
|
||||||
if (reducedMotion) renderFrame(0, 0);
|
if (reducedMotion) renderFrame(0, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ export default function Main() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
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: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(" ")}
|
].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>
|
</div>
|
||||||
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw]">
|
<Stack className="relative z-10 w-full max-w-full xl:max-w-[50vw]">
|
||||||
<Span data-reveal="fade">Benjamin Toby</Span>
|
<Span data-reveal="fade">Benjamin Toby</Span>
|
||||||
|
|||||||
@@ -114,3 +114,42 @@ html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
|
|||||||
padding-bottom: 0.08em;
|
padding-bottom: 0.08em;
|
||||||
margin-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