Fifteen pointer and scroll effects for React that share a single animation loop.
Live demo with every effect running on real photos, and the prompt that builds each one if you would rather have an assistant write it into your own components.
I build marketing pages, and on almost every one of them I ended up writing the same forty lines: listen to pointermove, normalise the coordinates, lerp towards a target, write a translate3d. Copy, paste, tweak the numbers, ship.
That worked fine until a page needed four of these at once. Then I had four requestAnimationFrame loops running at the same time, each one calling getBoundingClientRect inside the frame, and the scroll went from smooth to sticky. The profiler showed exactly what you would expect: forced synchronous layout, over and over, sixty times a second.
So I stopped copying the forty lines and put the loop, the pointer, and the rect measuring in one place, then built the effects on top of that. This is that. Forty elements on a page still means one loop, one pointermove listener, and one cached rect per element.
It is not the fanciest parallax library out there. It is the one I actually reach for.
npm i parallax-fxReact 17 or newer. No dependencies, no stylesheet to import, no provider to put at the root of your app.
import { Tilt3D } from 'parallax-fx';
<Tilt3D max={14} glare>
<img src="/photo.jpg" alt="" />
</Tilt3D>That is the whole setup. Every component styles itself inline and scopes those styles to the element it owns.
If you would rather own the code than depend on it, every effect is also a shadcn registry item:
npx shadcn@latest add https://studygeorge.github.io/parallax-fx/r/tilt-3d.jsonThat drops the component in components/parallax-fx/ and the shared loop in lib/parallax-fx/, with the imports already pointing at your aliases. Tailwind is not required, since nothing here uses classes. The full list is at /r/registry.json, and there is an llms.txt if you want to hand the catalog to an assistant.
| What it does | |
|---|---|
ParallaxScene + ParallaxLayer |
Layers at different depths that drift under the pointer |
Tilt3D |
A card that tilts in 3D, with an optional moving highlight |
Magnetic |
A button that leans towards the pointer as it gets close |
Spotlight |
A soft light that follows the pointer across a card |
ImageTrail |
A trail of pictures spawned by pointer distance |
ParallaxWindow |
A photo that drifts inside a fixed frame |
Cursor |
A ring that trails the pointer and swells over links |
ScrollParallax |
Anything, given a scroll speed |
KenBurns |
A slideshow where each photo pushes in and drifts |
CurtainReveal |
A moving mask that uncovers an element on entry |
StickyScrub |
A section that pins while you scroll, and hands you its progress |
CardStack |
Cards that pin one after another and settle into a stack |
TextReveal |
A line of text arriving word by word or letter by letter |
ScrollProgress |
A bar that fills with the page, or with one article |
Marquee |
An endless ticker that never touches the frame loop |
And the hooks, if you want the motion without the wrapper element: useMouseParallax, useScrollParallax, useScrollProgress, usePointer, useReducedMotion, useInViewFlag.
Give each layer a depth. Distant things want small numbers, near things want large ones. Negative sends a layer the other way.
import { ParallaxScene, ParallaxLayer } from 'parallax-fx';
<ParallaxScene strength={46} damping={0.075} style={{ height: 420 }}>
<ParallaxLayer depth={0.18}><img src="/lake.jpg" alt="" /></ParallaxLayer>
<ParallaxLayer depth={0.6}><img src="/hills.jpg" alt="" /></ParallaxLayer>
<ParallaxLayer depth={1.4} rotate={2}><h1>Preikestolen</h1></ParallaxLayer>
</ParallaxScene>One scene is one frame task, however many layers you put inside it. The pointer is measured once per frame instead of once per layer.
<Tilt3D max={12} scale={1.03} glare lift={20}>
<img src="/photo.jpg" alt="" />
</Tilt3D>The tilt only engages while the pointer is actually over the card and eases back to flat when it leaves. A grid of these does not twitch at you from across the page.
<Magnetic radius={140} strength={0.4} contentStrength={0.15}>
<button>Get in touch</button>
</Magnetic>The pull fades to nothing at the edge of the radius instead of snapping on, so you do not get the jolt a plain distance threshold gives you. contentStrength moves the label a little further than the button, which reads as depth.
<ParallaxWindow src="/fjord.jpg" ratio="16 / 9" mode="both" zoom={1.22} />The photo is scaled up by zoom and the leftover overflow is the entire travel budget. The movement is clamped to it, so no matter how hard someone scrubs the pointer you never see a bare edge slide into the frame.
<ScrollParallax speed={-0.14}><img src="/sky.jpg" alt="" /></ScrollParallax>
<ScrollParallax speed={0.18}><CardGrid /></ScrollParallax>Negative holds an element back, positive pushes it ahead. The offset is measured from the element's distance to the middle of the viewport, so a layer sits exactly where you placed it in the markup when it is centred, and drifts symmetrically on the way in and on the way out.
{photos.map((src, i) => (
<CurtainReveal key={src} delay={i * 90}>
<img src={src} alt="" />
</CurtainReveal>
))}The mask and the content move in opposite directions. That small counter movement is what separates it from a plain fade.
<StickyScrub length={2}>
<figure style={{ transform: 'scale(calc(0.85 + var(--pfx-progress) * 0.15))' }}>
<img src="/fjord.jpg" alt="" />
</figure>
</StickyScrub>This is the one people usually install GSAP for. The pinning itself is plain position: sticky, so the browser holds the panel and nothing can drift out of sync, and the only thing measured per frame is the outer container. Progress from 0 to 1 lands in --pfx-progress, so most of the time you never write a callback at all.
<TextReveal as="h2" by="word" text="Split the words, keep the sentence" />Splitting text is where most implementations quietly break screen readers: once the words are separate elements they get announced as separate fragments. The wrapper keeps the whole string in aria-label and every piece is hidden from the accessibility tree, so what is read out is exactly what you passed in.
<Marquee speed={40} gap={48} pauseOnHover fade={80}>
{logos.map((src) => <img key={src} src={src} alt="" />)}
</Marquee>No frame loop here at all. The track gets one repeating Web Animations API animation, which the browser runs on the compositor, so a marquee costs the same whether the tab is busy or idle. The number of copies is measured from the container, so a wide screen gets more and a narrow one does not carry markup it will never show.
const ref = useMouseParallax<HTMLImageElement>({ strength: 40, damping: 0.08 });
return <img ref={ref} src="/card.jpg" alt="" />;This is the part I actually care about, and the reason the library exists at all.
One loop. Every effect registers a callback through onFrame. There is exactly one requestAnimationFrame chain for the whole page, and when the last effect unmounts it stops.
The loop parks itself. Each callback returns whether it is still moving. Once every effect on the page reports that it has settled, the loop stops after a few frames of grace and the page goes to zero CPU. Anything that can change a target, a pointer move, a scroll, a resize, an element entering the viewport, wakes it up again. An idle page with twenty effects on it runs no frames at all.
Damping is corrected for the frame delta. The naive current += (target - current) * 0.1 moves twice as fast at 120fps as it does at 60fps, which is why the same code feels silky on one machine and twitchy on another. Here it is current + (target - current) * (1 - Math.pow(1 - factor, dt)) with dt normalised to 60fps frames, so the motion is the same on a 60Hz laptop and a 165Hz monitor. The delta is capped at four frames so a backgrounded tab does not produce one huge jump when you come back to it.
One pointer listener. The pointer store is reference counted. Ten components asking for the pointer still means one pointermove listener on the window, and the position is normalised once per event rather than once per effect.
Rects are cached. Calling getBoundingClientRect inside a frame task forces a synchronous layout, and doing that for every effect is where scroll jank usually comes from. Each rect is measured once and only re-measured after a scroll, a resize, or a ResizeObserver hit.
Off screen effects drop out. Visibility comes from an IntersectionObserver written into a ref rather than into React state, so scrolling past a page full of effects causes zero re-renders.
Reduced motion is a real check, not a token one. Under prefers-reduced-motion the listeners are never attached at all and the markup renders in its resting state.
Server rendering is fine. Nothing touches window at module scope, layout effects fall back to plain effects on the server, and the first client render matches the markup. The Ken Burns pan directions come from a fixed list rather than Math.random for the same reason.
Every effect writes CSS custom properties on its element as well as the transform, so you can compose your own motion without forking anything:
| Property | Where |
|---|---|
--pfx-x, --pfx-y |
pointer driven effects, in pixels |
--pfx-scroll |
ScrollParallax, in pixels |
--pfx-progress |
useScrollProgress, 0 to 1 |
--pfx-glare-x, --pfx-glare-y |
Tilt3D highlight position |
--pfx-light-x, --pfx-light-y |
Spotlight position |
Pass applyTransform={false} and the effect writes the numbers but leaves transform alone for you to use.
If you want the raw loop for something of your own:
import { onFrame, getPointer, damp } from 'parallax-fx';
useEffect(() => onFrame((dt) => {
const pointer = getPointer();
// return true while you are still moving, so the loop knows not to park
return true;
}), []);Pointer effects can read device orientation instead, which keeps parallax scenes alive on touch devices.
import { requestDeviceOrientationPermission } from 'parallax-fx';
<ParallaxScene deviceOrientation>...</ParallaxScene>iOS needs DeviceOrientationEvent.requestPermission() from a real user gesture first, which is why it is opt in rather than automatic. Call requestDeviceOrientationPermission() from a click handler.
npm install
npm run dev # demo site on localhost:5173
npm test # core tests
npm run typecheck
npm run buildIf the damping curve feels wrong to you, or an effect misbehaves in a browser I have not tried, please open an issue. I would rather hear about it than not. Same goes for effects you think are missing, though I would like to keep the set small enough that the whole thing stays readable.
MIT licensed. Photos on the demo site are mine.