Hinged Container Intro
A page-enter that tips its container up from below the fold like a lid rather than sliding it in. The panel starts at 120% of its own height, rotated -45deg in X about its bottom edge, and lands over 1.4s on expo.out; captions inside it stagger in starting 0.6s before it arrives. A cue fires at 0.8s so an expensive scene can start while the panel is still in flight.
Hinged Container Intro
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/hinged-container-intro.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Page body. Rendered inside the hinging panel. |
| routeKey | string | — | Change to replay. Pass usePathname() in an App Router layout. |
| duration | number | = 1.4 | Panel flight time in seconds. |
| tilt | number | = -45 | Starting X rotation in degrees. |
| travel | number | = 120 | Starting offset as a percentage of the panel's own height. |
| perspective | number | = 1200 | Perspective on the wrapper, in pixels. |
| detailSelector | string | = '[data-hinge-detail]' | Descendants that stagger in behind the panel. |
| onSceneCue | () => void | — | Called sceneCue seconds in. Start expensive work here. |
| sceneCue | number | = 0.8 | Absolute timeline position of the cue, in seconds. |
| className | string | — | Merged onto the perspective wrapper. |
How it works
The technique in order, not the code in order.
A wrapper carries
perspective: 1200pxand the panel inside it getstransformOrigin: "center bottom"plustransformPerspective, so the rotation pivots on the edge the panel is about to rest on and the top edge sweeps a much longer arc than the bottom.The panel tween is
fromTo(el, { yPercent: 120, rotateX: -45, opacity: 0 }, { yPercent: 0, rotateX: 0, opacity: 1, duration: 1.4, ease: "expo.out" }).yPercentrather than a viewport unit means the same component works inside a 320px card and a full-bleed layout without re-tuning.Elements matching
detailSelectorare collected withquerySelectorAllon the panel and appended at"-=0.6", so their 0.9s stagger overlaps the tail of the flight and the two moves read as one gesture.A
tl.call(cue, undefined, 0.8)sits at an absolute timeline position, so the cue stays at 0.8s even if either tween is retimed. That is where a WebGL scene, a videoplay(), or a data fetch is kicked off — mid-flight, while the eye is on the moving panel.The whole timeline is built inside
useGsap, keyed onrouteKey; a key change reverts the previous context (restoring the inline styles GSAP wrote) and builds a fresh timeline from frame zero.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| duration | = 1.4 | Panel flight in seconds. Under ~1.1s the expo.out tail is too short to read as deceleration and the panel appears to snap; over ~1.8s the visitor is waiting on chrome before they can read anything. |
| tilt | = -45 | Degrees of X rotation at the start. At -20deg the perspective foreshortening is barely visible so it looks like a plain slide; past about -70deg the panel is nearly edge-on and its content is an unreadable sliver for most of the flight. |
| travel | = 120 | Percent of the panel's own height it starts below rest. 100 would put its top edge exactly at the bottom of its box, but the rotation lifts the top edge upward, so 100 lets a corner peek in at frame zero. The extra 20% covers the rotated bounding box. |
| perspective | = 1200 | Pixels. Around 600 the lens is wide enough that the panel visibly skews toward one corner; above ~2000 the projection is near-orthographic and the hinge stops reading as depth. |
| DETAIL_OVERLAP | = 0.6 | Seconds the caption stagger starts before the panel lands. At 0 the two moves read as two separate events with a dead beat between them; beyond ~0.9 the captions are moving while the panel is still steeply rotated and the text shears. |
| DETAIL_STAGGER | = 0.1 | Seconds between captions. Fast enough that three to five items finish inside the panel tween, slow enough that each is individually perceptible. |
| sceneCue | = 0.8 | Seconds into the timeline before the heavy cue fires. Late enough that the panel has visually committed to arriving (so a first-frame stall is hidden by motion), early enough that a 1.6s scene intro finishes about 1s after the panel lands rather than long after. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No timeline is built. A prefers-reduced-motion rule in the component's own stylesheet cancels the opacity: 0 start state, so the panel and its captions render in their final position immediately, and onSceneCue fires on mount so anything it gates still loads.
useGsap reverts the gsap.context on unmount and on every routeKey change, which kills the timeline and restores the inline styles GSAP wrote to the panel and captions. The cue callback lives in a ref that React discards with the component, so a call queued at 0.8s cannot reach a stale closure.
On mount the panel rises from below with its top edge visibly further away than its bottom, settles without overshoot, and the captions land about a third of a second later. Switching route replays it from frame zero with no flash of the previous content. With reduced motion on, the panel is simply there, and the cue still fires.
Pitfalls
- Setting the start state only inside the effect.
useEffectcan run after the browser has painted, so the finished page flashes for one frame and then jumps below the fold. The start state is a CSS rule on[data-hinged-panel]; the reduced-motion branch of that same rule is what keeps the content from depending on JS at all. - Rotating without
transformOrigin: "center bottom". About the centre the panel appears to tumble through its own resting position, and the bottom edge dips below the fold on the way in. - Putting
perspectiveon the panel instead of an ancestor. Then any transformed child gets its own vanishing point and the parallax between panel and captions is wrong at the edges. - Positioning the cue with a relative
"-=0.6"string. Retiming the panel tween silently moves the cue with it; a numeric position pins it to 0.8s absolute. - Firing the cue only in the animated branch. Reduced-motion users then never get the scene, the video or the fetch it was gating — the cue is content delivery, not decoration.
- Passing an inline arrow as
onSceneCueand putting it in the effect deps. Every parent render rebuilds the timeline and the intro restarts mid-flight; the callback is held in a ref instead.
Notes
- App Router wiring: render this inside
app/layout.tsxaround{children}and passrouteKey={usePathname()}. The component itself never imports a router, so the same file works in Pages Router, Remix, or a modal keyed on a record id. - The cue is not a substitute for a load event. If the thing it starts can fail, give it its own timeout — see
safe-preloaderfor the ceiling pattern.