Skip to content

Repository files navigation

parallax-fx

Fifteen pointer and scroll effects for React that share a single animation loop.

npm bundle license

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.

Русская версия

Why I made this

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.

Install

npm i parallax-fx

React 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.

Or copy one effect into your project

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.json

That 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.

The effects

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.

Examples

Layered scene

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.

Tilt with a highlight

<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 button

<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.

Photo in a frame

<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.

Scroll

<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.

Reveal on entry

{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.

Pin a section and scrub it

<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.

Text that arrives in pieces

<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.

Endless ticker

<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.

Just the hook

const ref = useMouseParallax<HTMLImageElement>({ strength: 40, damping: 0.08 });

return <img ref={ref} src="/card.jpg" alt="" />;

How it works inside

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.

Escape hatches

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;
}), []);

Phone tilt

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.

Development

npm install
npm run dev        # demo site on localhost:5173
npm test           # core tests
npm run typecheck
npm run build

Comments welcome

If 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.

About

Fifteen pointer and scroll effects for React. One shared rAF loop, no dependencies, no CSS import, SSR safe.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages