Transform the MacBook in a 3D scene... animate the lid opening... play your product video on screen...
Live demo · Quick start · API · Changelog
npm i rigged-macbook-3d three @react-three/fiber @react-three/dreiimport { Macbook, MacbookStage } from 'rigged-macbook-3d';
export default function Hero() {
return (
<div style={{ height: 600 }}>
<MacbookStage lighting="studio-dark">
<Macbook open={1} screen="/demo.mp4" />
</MacbookStage>
</div>
);
}That's the whole integration: a lit stage, an open MacBook, your video on the screen. The package
itself is 6.4 kB gzipped with zero runtime dependencies — three and R3F stay peers, so it adds
nothing you aren't already shipping.
<MacbookScroll> is the full pinned journey — the device rises in, the lid swings open onto
your playing video, the journey holds there while the user keeps scrolling, then the device
recedes and scroll hands off to the rest of the page. No GSAP, no Lenis, no scroll library at all.
import { MacbookScroll } from 'rigged-macbook-3d';
<MacbookScroll
height="600vh"
screen={{ src: '/demo.webm', fallbackSrc: '/demo.mp4' }}
/>Every beat is a [start, end] pair on 0–1 scroll progress, and every one is overridable:
| Beat | Default | What happens |
|---|---|---|
deviceIn |
0 → 0.18 |
Device fades and rises into frame |
lidOpen |
0.24 → 0.48 |
Lid rotates open, unhurried; the screen wakes behind it |
dive |
0.42 → 0.64 |
Camera eases in toward the display |
| hold | 0.64 → 0.74 |
The gap before recede — the open MacBook plays its video while the user scrolls through it |
recede |
0.74 → 0.87 |
Pushes back so the whole laptop is visible again |
| settle | 0.87 → 1 |
The tail after recede — the device rests, fully composed, before the page scrolls on |
Make the hold longer or shorter by moving recede, or give the whole journey more scroll room
with height. The settle is generous on purpose: the damped follow trails raw scroll, so the
pushback needs that runway to finish and rest before the pin releases — trim it and a quick
scroll will carry the exit into the page's own motion. Pass timeline, poses, or feel to override any of it; anything you leave out
keeps the tuned default. demo/src/Journey.tsx
is the complete example.
Layered so each one is useful on its own.
| Component | What it's for |
|---|---|
<Macbook> |
The rigged model. Headless and controlled — you drive open, brightness, and the screen content from whatever state you already have. |
<MacbookStage> |
A ready-made <Canvas> with the tuned camera and lighting. Skip it if you already have an R3F scene. |
<MacbookLighting> |
The three lighting presets on their own, for dropping into an existing scene. |
<MacbookScroll> |
The whole scroll journey above, in one component. |
Because <Macbook> is controlled, it binds to anything: a useState, a scroll position, a
spring, a Framer Motion value.
<Macbook> — the rigged model
| Prop | Type | Default | Description |
|---|---|---|---|
open |
number |
1 |
Lid amount: 0 = closed, 1 = fully open. Linear — apply your own easing. |
screen |
ScreenInput |
— | Screen content: a video URL, image URL, or ready THREE.Texture. |
brightness |
number |
1 |
Screen wake: 0 = black, 1 = full. |
autoPlay |
boolean |
true |
Play/pause the screen video so it only decodes while visible (paused while the lid is shut). |
modelSrc |
string |
— | Self-hosting escape hatch — see The model below. |
onLoad |
() => void |
— | Fires once the model is rigged and ready. |
frameDriver |
() => MacbookFrameState |
— | Per-frame state source, called inside the render loop. Returned fields (open, brightness) override the matching props — drive the model without re-rendering React. |
A ScreenInput is a URL string, a THREE.Texture, or { src, type?, fallbackSrc? }.
Video vs image is sniffed from the extension unless you set type.
<Macbook> also takes every prop an R3F <group> takes (position, rotation, scale, …) and
forwards a ref to the underlying THREE.Group.
<MacbookStage> — the canvas wrapper
| Prop | Type | Default | Description |
|---|---|---|---|
lighting |
LightingPreset |
'studio-dark' |
Lighting preset for the stage. |
lightingIntensity |
number |
1 |
Scales the preset's lights. |
pauseWhenOffscreen |
boolean |
true |
Park the frameloop (and the GPU) while the stage is scrolled out of view. |
className / style |
— | — | Size the wrapper div with these; defaults to 100% × 100%. |
Ships a tuned camera (z 6, fov 32) and ACES filmic tone mapping. Accepts every <Canvas> prop, so
you can override camera, dpr, gl, and the rest.
<MacbookLighting> — the presets
| Prop | Type | Default | Description |
|---|---|---|---|
preset |
'studio-dark' | 'studio-light' | 'soft' |
'studio-dark' |
studio-dark is the tuned Space Black look — a dark body carried by a few crisp reflections. |
intensity |
number |
1 |
Scales every light in the preset. |
children |
ReactNode |
— | Escape hatch: children replace the preset entirely. |
<MacbookScroll> — the scroll journey
| Prop | Type | Default | Description |
|---|---|---|---|
screen |
string | ScreenSource |
— | The video (or image) that plays on the screen once the lid opens. |
height |
string |
'600vh' |
Total scroll length of the pinned journey. |
lighting |
LightingPreset |
'studio-dark' |
Lighting preset. |
timeline |
Partial<Timeline> |
— | Override any journey beats. |
poses |
PosesPartial |
— | Override the intro / dive / outro poses. |
feel |
Partial<Feel> |
— | Tune the damped follow: smooth time, max speed. |
pointerParallax |
boolean |
true |
Cursor-follow tilt once dived in. |
fallback |
ReactNode |
null |
Rendered instead of the journey without WebGL2, or under reduced motion. |
modelSrc |
string |
— | Self-hosting escape hatch. |
onProgress |
(p: number) => void |
— | Fires with the smoothed 0–1 progress. |
children |
ReactNode |
— | Overlay content, rendered inside the sticky viewport above the canvas. |
The forwarded ref exposes a read-only progress getter.
Hooks & utilities
| Export | What it does |
|---|---|
useCapabilityGate() |
boolean | null — should this client get 3D? (WebGL2 + not reduced-motion). null on first render, so it's SSR-safe. |
useScreenTexture(source) |
Turns a URL or texture into a THREE.Texture, with setPlaying to control video decode. |
journeyState(...) |
The pure progress → frame-state mapping. Call it yourself to keep DOM overlays in exact sync with the 3D scene. |
ramp, easeInOut, lerp, clamp01, smoothDamp |
The math the journey is built from. |
DEFAULT_TIMELINE, DEFAULT_POSES, DEFAULT_FEEL, … |
The tuned defaults, plus resolveTimeline/resolvePoses/resolveFeel to merge over them. |
Open the lid on hover
const target = useRef(0);
const now = useRef(0);
<div onPointerEnter={() => (target.current = 1)} onPointerLeave={() => (target.current = 0)}>
<MacbookStage>
<Macbook
screen="/demo.mp4"
frameDriver={() => {
now.current += (target.current - now.current) * 0.08;
return { open: now.current };
}}
/>
</MacbookStage>
</div>Bind the lid to a Framer Motion value
frameDriver reads the value every frame, so nothing re-renders:
const openMV = useMotionValue(0);
<Macbook screen="/demo.mp4" frameDriver={() => ({ open: openMV.get() })} />
// elsewhere: animate(openMV, 1, { type: 'spring', stiffness: 120, damping: 20 });Rotate with the mouse
const groupRef = useRef<THREE.Group>(null);
useFrame((state) => {
if (!groupRef.current) return;
groupRef.current.rotation.y = (state.pointer.x * Math.PI) / 8;
groupRef.current.rotation.x = (state.pointer.y * Math.PI) / 16;
});
<group ref={groupRef}>
<Macbook open={1} screen="/demo.mp4" />
</group>Bring your own lighting
<MacbookStage>
<MacbookLighting>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 8, 6]} intensity={1.2} />
</MacbookLighting>
<Macbook open={1} screen="/demo.mp4" />
</MacbookStage>Next.js
Everything here is a client component. With the App Router, load the stage through next/dynamic
with SSR off:
'use client';
import dynamic from 'next/dynamic';
const MacbookStage = dynamic(() => import('rigged-macbook-3d').then((m) => m.MacbookStage), { ssr: false });The model
By default the GLB loads from https://unpkg.com/rigged-macbook-3d@<version>/assets/macbook-rigged.glb,
pinned to the version you installed so it never changes underneath you. To self-host, copy
node_modules/rigged-macbook-3d/assets/macbook-rigged.glb into your static assets and pass its URL
as modelSrc.
No other model works. The rig — the hinge split, the LidPivot/Screen node names, the
seat-lift math that keeps the closing screen out of the keyboard — is welded to this specific file,
and a different GLB throws. You customise through props and lighting, not by bringing your own
geometry.
Multiple <Macbook> instances on one page are fine: each clones the node hierarchy on mount while
geometries and materials stay shared, so extra instances are cheap.
Fallbacks & accessibility
<MacbookScroll> gates itself on useCapabilityGate() — WebGL2 present, prefers-reduced-motion
not set. It renders a height-holding placeholder on first render (SSR-safe), then either the
journey or your fallback. Once it downgrades it never reverts, since remounting a 3D scene under
someone is its own kind of motion.
<MacbookStage> and <Macbook> do not gate themselves. Using them bare, call
useCapabilityGate() yourself and branch on it. Make the fallback meaningful — a screenshot, a
video, a description — not an empty div. A real share of your visitors will only ever see it.
Troubleshooting
Screen renders black with a cross-origin video. Every <video> is created with
crossOrigin="anonymous", so the host must send CORS headers or the browser won't let WebGL sample
the frames — and it fails silently. Serving the video from your own origin sidesteps it.
Model won't load / unpkg is blocked. Self-host the GLB and pass modelSrc (see The model, above).
Duplicate three version warnings. Two copies of three in one bundle silently break
materials and textures. Run npm ls three, then pin a single version with overrides (npm) or
resolutions (Yarn/pnpm).
Scroll feels rough in dev. StrictMode's double-mounting is handled. It's usually the host page
forcing synchronous layout — reading getBoundingClientRect/scrollY in an unthrottled scroll
listener, or a ResizeObserver loop — on the same frame as the damped follow.
License MIT © William Laverty
Model: "MacBook Pro M3 16-inch 2024" by jackbaeten, CC-BY 4.0, rigged and recoloured — full attribution in CREDITS.md.
Not affiliated with or endorsed by Apple Inc. "MacBook" is a trademark of Apple Inc., used nominatively.