diff --git a/src/components/general/page-hero.tsx b/src/components/general/page-hero.tsx
index a47aa69..99b928d 100644
--- a/src/components/general/page-hero.tsx
+++ b/src/components/general/page-hero.tsx
@@ -40,14 +40,20 @@ export default function PageHero({
>
-
+
{eyebrow && (
diff --git a/src/components/lib/three/SceneCanvas.tsx b/src/components/lib/three/SceneCanvas.tsx
index 87dd4ea..79804fb 100644
--- a/src/components/lib/three/SceneCanvas.tsx
+++ b/src/components/lib/three/SceneCanvas.tsx
@@ -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(null);
const canvasRef = React.useRef(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();
diff --git a/src/components/lib/three/stage.ts b/src/components/lib/three/stage.ts
index c20e0a3..d27e059 100644
--- a/src/components/lib/three/stage.ts
+++ b/src/components/lib/three/stage.ts
@@ -37,12 +37,20 @@ export type SceneFactory = (
stage: Stage,
) => SceneInstance | Promise;
+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);
}
diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx
index a0ca359..36d7769 100644
--- a/src/components/pages/Home/index.tsx
+++ b/src/components/pages/Home/index.tsx
@@ -20,12 +20,19 @@ export default function Main() {
>
-
+
Benjamin Toby
diff --git a/src/styles/globals.css b/src/styles/globals.css
index dd6c2d5..1895ffd 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -114,3 +114,42 @@ html.motion:not(.motion-fallback) [data-reveal-stagger] > * {
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
+
+/* Hero graphics (