matchMedia Layout Teardown
A fanned card layout on desktop and a plain column on mobile, with the entire desktop branch registered inside `gsap.matchMedia()`. GSAP records every `set`, tween and returned cleanup made inside the callback and reverts them the moment the query stops matching, so crossing the breakpoint leaves no inline transforms, no live listeners and no half-applied layout behind.
matchMedia Layout Teardown
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/matchmedia-layout-teardown.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
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | One card per child. Each is wrapped in the element GSAP positions. |
| breakpoint | number | = 768 | Pixel width at which the fan layout takes over. |
| spread | number | = 15 | Horizontal step per card, as a percentage of container width. |
| arc | number | = 4 | Vertical drop per step from centre, as a percentage of container height. |
| tilt | number | = 5 | Degrees of rotation per step from centre. |
| parallax | number | = 26 | Maximum pointer parallax in pixels for the outermost card. |
| className | string | — | Merged onto the container. |
| itemClassName | string | — | Merged onto every card wrapper. Give the cards a width here — the fan does not measure them. |
How it works
The technique in order, not the code in order.
gsap.matchMedia().add({ isDesktop, isMobile, reduced }, self => …)uses the object form: several queries are evaluated at once and arrive as booleans onself.conditions, so one callback branches on width and on motion preference together instead of needing anaddper combination. GSAP re-runs the callback when any of the queries flips, which means toggling the OS "reduce motion" setting reconfigures the layout live, without a reload.The desktop branch positions the cards entirely through
gsap.set—position: absolute,left,top,rotation,zIndex— rather than through breakpoint utility classes. That is deliberate: the breakpoint then exists in exactly one place, the query string, and the base markup can stay a plain flex column that doubles as the mobile layout.Positions are percentages of the container (
left: 50 + offset * spread%,top: 50 + |offset| * arc%) withxPercent: -50, yPercent: -50for centring. Because percentages resolve against the container box, the fan rescales on resize with no measuring pass, noResizeObserverand no resize listener inside the branch.Layout and interaction are kept on separate transform channels.
left/topcarry the fan, the entrance tween ownsyandautoAlpha, and the pointer parallax writes onlyxviagsap.quickTo. Two animations sharing one property means one of them silently loses, usually the one that started first.The callback returns a cleanup function that removes the pointer listeners. GSAP invokes it when the branch stops matching, not only on unmount, so a desktop-only listener cannot survive into the mobile layout.
On teardown the effect calls
mm.revert(), which restores the inline styles GSAP wrote — including theposition: absolute— so the authored column comes back unmodified.mm.kill()would stop the tweens but leave the fan frozen exactly where it was.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| breakpoint | = 768 | Where the fan takes over. The mobile query is deliberately max-width: breakpoint - 1 rather than max-width: breakpoint: overlapping queries both match at exactly 768px and the two branches register at once, which is how a card ends up absolutely positioned inside a column. |
| spread | = 15 | Percent of container width per card step. At this value a five-card fan spans about 60% of the box with visible overlap. Past ~22% the outer cards leave the container; under ~8% they stack almost exactly and the fan reads as a printing error. |
| arc | = 4 | Percent of container height each step drops. A small positive arc puts the centre card highest, which is what makes the row read as a hand of cards rather than a straight strip. Zero is flat; past ~10% the outer cards fall out of the box bottom. |
| tilt | = 5 | Degrees per step, applied about a 50% 90% origin so the cards rotate around their lower edge like a fan pivot. Rotating about the centre swings the tops and bottoms in opposite directions and the arc stops reading. |
| parallax | = 26 | Maximum pixels the outermost card travels with the pointer, scaled by its distance from centre. Enough to give the fan depth; past ~50px the cards visibly separate from their own shadows and the layout looks unstable. |
| ENTER_DURATION / ENTER_STAGGER | = 0.7 / 0.06 | A five-card fan finishes in 0.7 + 4 x 0.06 = 0.94 s. Longer staggers make the last card arrive after the user has already started reading; shorter ones collapse the sequence into a single flash. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
(prefers-reduced-motion: reduce) is one of the queries in the same add block. When it matches, the desktop branch still composes the full fan with gsap.set and then returns early — no entrance tween, no pointer listener. The layout is identical; only the movement is gone. Because it is a matchMedia condition rather than a mount-time read, changing the OS setting reconfigures a mounted component immediately.
mm.revert() on unmount reverts every recorded set and tween back to the pre-branch inline styles and runs each branch's returned cleanup, which removes the pointermove and pointerleave listeners. Nothing else is registered, so there is nothing else to release.
Above 768px the cards fan out from the centre with a stagger, tilt outward, and drift with the pointer. Drag the window narrower past 768px and they return to a plain vertical column with no leftover transforms and no inline position: absolute on any card. Widen again and the fan re-registers and re-plays its entrance. Resize within the desktop range and the fan rescales with the box. Enable reduce-motion while it is open and the fan stays but stops moving.
Pitfalls
- Registering animations once at mount and relying on CSS to hide them below the breakpoint. Resize past a breakpoint and a desktop-only ScrollTrigger keeps pinning on mobile, leaving a section stuck: the pin spacer is still in the document, the section will not scroll past, and nothing in the mobile stylesheet can undo it because the offending styles are inline.
matchMediareverts them instead. - Using
mm.kill()instead ofmm.revert(). Kill stops the tweens but leaves every inline style GSAP wrote, so the fan freezes in place and the column never comes back. - Overlapping queries:
(min-width: 768px)paired with(max-width: 768px). Both match at exactly 768px, both branches register, and the mobile teardown fights the desktop setup for one very confusing breakpoint width. - Adding an event listener inside the callback without returning a cleanup. The listener outlives the branch, so a desktop pointer handler keeps writing transforms to cards that are back in the mobile column.
- Putting the responsive layout in Tailwind breakpoint classes AND the animation in matchMedia. The breakpoint now exists in two places and they drift; the classic symptom is a layout that switches at 768px while its animation switches at 1024px.
- Measuring the container inside the branch and hard-coding pixel offsets. The branch does not re-run on resize — only on a query change — so the fan is correct at the width it was registered at and wrong at every other width until you cross the breakpoint twice.
- Treating
self.conditionsas always defined. It is optional, and withnoUncheckedIndexedAccesseach lookup isboolean | undefined; a non-null assertion here compiles and then readsundefinedas truthy-adjacent in the one edge case that matters.
Notes
- No ScrollTrigger is used here, but the failure mode described in the first pitfall is the reason this pattern exists — a pinned desktop section that survives into a mobile layout is the single most common GSAP responsiveness bug, and
matchMediais the sanctioned fix. - The same block also handles reduced motion: the
reducedcondition composes the final fan withgsap.setand returns before registering the entrance tween or the parallax listener, so the layout is identical and only the motion is absent. gsap.context()(via theuse-gsaphook) is the right tool when you need scoped selectors and cleanup but no breakpoints.matchMediaiscontextplus queries — reach for it the moment a layout differs across breakpoints, not just its timing.