Skip to content

Repository files navigation

@ouvarov/scroll-parallax

npm version bundle size types license downloads

One React component for scroll-driven animation. Translate, opacity, scale, rotate — all four scroll-animatable CSS properties in one component, under 1 KB gzipped per imported component. Plus <FadeOnView> and <RevealOnView>, one-tag wrappers for the common reveal-on-scroll cases, <ScrollProgress>, a page-level reading-progress bar, and <StickyShrink>, a sticky header that shrinks on scroll. The animation itself is pure CSS animation-timeline: view() (or scroll() for the page-level ones).

📺 Live demo & docs: https://ouvarov.github.io/scroll-parallax/

JS:   2.9 KB raw  /  1.2 KB gzipped  (all components; tree-shakes to 268–869 B per component)
CSS:  8.3 KB raw  /  1.9 KB gzipped

Install

npm i @ouvarov/scroll-parallax

Use

import { Parallax } from "@ouvarov/scroll-parallax";

export function Hero() {
  return (
    <Parallax amplitude={30}>
      <img src="/hero.jpg" alt="" />
    </Parallax>
  );
}

The CSS is auto-imported by the component — one line is all you need. The image drifts from +30px to -30px on the Y axis as it passes through the viewport. No useEffect, no IntersectionObserver, no scroll listener.

Custom start and end

<Parallax from={40} to={-20}>
  <img src="..." />
</Parallax>

Pulse — three-stop animation

Pass mid to drift in, then back out. Same trick for opacity (opacityMid).

<Parallax mid={0} from={40} to={40} opacityFrom={0} opacityMid={1} opacityTo={0}>
  <Card />
</Parallax>

Card translates from +40px to 0 then back to +40px, fading in at the apex and out again — a classic "rise and settle" pulse.

Scale and rotate

<Parallax amplitude={0} scaleFrom={0.8} scaleTo={1} rotateFrom={-4} rotateTo={0}>
  <img src="..." />
</Parallax>

rotateFrom / rotateTo take degrees as numbers. Scale is a unitless multiplier (1 = original size).

Opacity ramp

<Parallax amplitude={0} opacityFrom={0.2} opacityTo={1}>
  <img src="..." />
</Parallax>

Stagger — cascade across children

Set stagger and <Parallax> stops animating itself — instead each direct child runs the animation in a cascade. No mapping over children, no JS: each child's animation-range-start is shifted by an :nth-child rule.

<Parallax stagger={6} range="cover 0% cover 50%"
          from={40} to={0} opacityFrom={0} opacityTo={1}>
  {items.map((i) => <Card key={i} />)}
</Parallax>

range is the cascade window — you decide where the reveal lands. The cascade offsets each child's start inside it; the end is shared, so every child finishes at the same point. cover 50% is the viewport center, so the group is fully revealed by the time it's in the reading zone — not only at the very top (cover 100%). With stagger, give range in the explicit "<name> n% <name> n%" form (default cover 0% cover 50%).

The cascade unit is a percentage of view progress per child, not milliseconds — scroll-driven animations ignore time-based animation-delay. Child n starts at from + (n−1) × stagger%. Works best on a group that enters together (a row or grid). Pure CSS, capped at 24 children (beyond that they share the last offset); keep from + stagger × count under the range end.

Compose everything

<Parallax
  amplitude={80}
  axis="y"
  opacityFrom={0.3}
  opacityTo={1}
  scaleFrom={0.95}
  scaleTo={1}
  range="entry 0% cover 60%"
  easing="cubic-bezier(0.7, 0, 0.3, 1)"
>
  <img src="..." />
</Parallax>

FadeOnView — the common case

Fade in as the element enters the viewport. No opacity props, no range to remember.

import { FadeOnView } from "@ouvarov/scroll-parallax";

<FadeOnView>
  <Card />
</FadeOnView>

This is exactly <Parallax opacityFrom={0} opacityTo={1} range="entry">. Pass rise for an upward drift on the way in:

<FadeOnView rise={24}>
  <Card />
</FadeOnView>

The card starts 24px lower and settles as it fades. rise={0} (the default) is a pure fade.

RevealOnView — animate in at a threshold

The element stays hidden until it reaches a trigger line on the way up — by default the lower third — then plays a short ease-out reveal and holds. Where FadeOnView ramps across the whole entry, RevealOnView waits at the threshold, then reveals over a brief span.

import { RevealOnView } from "@ouvarov/scroll-parallax";

<RevealOnView>            {/* opacity 0 → 1, starting at 30% up */}
  <Card />
</RevealOnView>

<RevealOnView effect="scale">   {/* scale 0 → 1 */}
  <Card />
</RevealOnView>

<RevealOnView effect="both" threshold={0.5} span={0.1}>   {/* snappy grow + fade at center */}
  <Card />
</RevealOnView>

threshold={0.3} with span={0.15} maps to range="cover 30% cover 45%" — invisible below 30% of the pass, animating in across the next 15%, solid after. Shrink span for a snappier entrance.

It's still a scroll-driven animation: at normal scroll speed it reads as an entrance, but scrubbing slowly tracks the scroll, and it's reversible (scroll back up past the line and it hides again). A time-based one-shot would need JS, which this package deliberately avoids.

ScrollProgress — page reading bar

A fixed bar that fills as the whole page scrolls. Drop one tag at the root of your app — no scroll listener, no useState. Unlike the rest of the library it tracks the document with animation-timeline: scroll(), not a single element's view().

import { ScrollProgress } from "@ouvarov/scroll-parallax";

<ScrollProgress />                                  {/* 4px bar at the top, currentColor */}

<ScrollProgress height={6} color="#7c3aed" />       {/* thicker, accent color */}

<ScrollProgress position="bottom" />                {/* pin to the bottom edge */}

The fill is a composite-only scaleX, so the bar rides the compositor and never lays out or paints while you scroll. It stays active under prefers-reduced-motion — it mirrors the native scrollbar rather than adding decorative motion.

StickyShrink — header that shrinks on scroll

A position: sticky header that shrinks from a tall variant to a compact one over the first distance px of page scroll. No scroll listener, no useScroll.

import { StickyShrink } from "@ouvarov/scroll-parallax";

<StickyShrink>                              {/* 80px → 56px over 200px of scroll */}
  <Logo />
  <Nav />
</StickyShrink>

<StickyShrink from={120} to={64} distance={300}>
  <Header />
</StickyShrink>

It's driven by a single registered progress variable, --ouvarov-sticky-progress (0 → 1), animated on animation-timeline: scroll(root block). Children inherit it, so they can react to the same scroll without any extra wiring:

<StickyShrink>
  {/* logo scales down as the header collapses */}
  <img style={{ scale: "calc(1 - 0.25 * var(--ouvarov-sticky-progress))" }} />
</StickyShrink>

Unlike the parallax components, shrinking a header reflows the page below it, so this one does touch layout — bounded to one element and its subtree, only while scrolling the first distance px. Under prefers-reduced-motion it stays at its tall size and never shrinks.

Props

<Parallax>

prop type default notes
amplitude number 30 Symmetric drift shortcut. Animates from +amplitude to -amplitude. Overridden by from/to.
from number Start translate in px.
mid number (from + to) / 2 Middle translate in px (50% keyframe). Set explicitly to create a pulse / three-stop drift.
to number End translate in px.
axis 'x' | 'y' 'y' Which axis to drift on.
opacityFrom number 1 Start opacity (0–1).
opacityMid number (opacityFrom + opacityTo) / 2 Middle opacity (50% keyframe). Set explicitly for fade-in-then-out pulses.
opacityTo number 1 End opacity (0–1).
scaleFrom number 1 Start scale multiplier.
scaleTo number 1 End scale multiplier.
rotateFrom number 0 Start rotation in degrees.
rotateTo number 0 End rotation in degrees.
stagger number When set, the wrapper isn't animated — each direct child runs the animation, offset by this percentage of view progress per child. range sets the window; children share its end. Pure CSS via :nth-child, capped at 24 children.
range string 'cover 0% cover 100%'
('cover 0% cover 50%' with stagger)
Any valid animation-range — where in the scroll the animation plays. With stagger, it's the cascade window (use the "<name> n% <name> n%" form); cover 50% ends at the viewport center.
easing string 'linear' Any valid animation-timing-function.
className string Merged onto the wrapper.
style CSSProperties Merged onto the wrapper.
children ReactNode Whatever you want to animate.

<FadeOnView>

Sugar over <Parallax>. Forwards className, style and easing unchanged.

prop type default notes
rise number 0 Upward drift in px on the way in. 0 is a pure fade.
range string 'entry' Where the fade plays — defaults to the element's entry into the viewport.

<RevealOnView>

Sugar over <Parallax>. Forwards className and style unchanged.

prop type default notes
effect 'fade' | 'scale' | 'both' 'fade' fade ramps opacity 0→1, scale grows from 0→1, both combines them. Eases in with ease-out.
threshold number 0.3 Trigger line — fraction of the element's pass where the reveal starts. Below it, the element is hidden. Clamped to 0–1.
span number 0.15 How much scroll the reveal takes after the threshold. Together: range="cover {threshold×100}% cover {(threshold+span)×100}%". Smaller = snappier.

<ScrollProgress>

Standalone page-level bar — renders its own fixed <div>, takes no children.

prop type default notes
height number | string 4 Bar thickness. Number → px; string used as-is.
color string currentColor Fill color.
position 'top' | 'bottom' 'top' Which viewport edge to pin to.
zIndex number 2147483647 Stacking order — defaults high so it sits above page chrome.
className string Merged onto the bar.
style CSSProperties Merged onto the bar.

<StickyShrink>

A position: sticky header. Exposes --ouvarov-sticky-progress (0 → 1) to its subtree.

prop type default notes
from number | string 80 Tall height before scroll. Number → px; string used as-is.
to number | string 56 Compact height after shrinking. Number → px; string used as-is.
distance number | string 200 Scroll distance from the top over which it shrinks. Number → px.
className string Merged onto the header.
style CSSProperties Merged onto the header.
children ReactNode Header content. Read var(--ouvarov-sticky-progress) to react to the shrink.

The component renders a <div> wrapper. The wrapper is the animated element.

Each <Parallax> instance is scoped — values don't bleed across siblings.

Browser support

Browser Status
Chrome / Edge / Opera ✅ 115+ (Aug 2023)
Safari ✅ 18+ (Sept 2024)
Firefox ⚠ Behind layout.css.scroll-driven-animations.enabled flag
iOS Safari ✅ 18+
Older browsers Element renders normally, no animation

Reduced motion is respected automatically via @media (prefers-reduced-motion: reduce).

Why this exists

animation-timeline: view() has been stable in Chromium since 2023 and Safari since 2024 — the browser reads scroll progress natively, no JavaScript needed. This package is a typed React wrapper over that native CSS API.

Use without React

The React components do one thing: render an element with a class and a few CSS custom properties. All the animation lives in style.css. So you don't actually need React — import the stylesheet, add the class, set the properties. The same classes work in Vue, Svelte, Astro, or plain HTML.

Via a bundler:

import "@ouvarov/scroll-parallax/style.css";

Or in plain HTML, straight from a CDN:

<link rel="stylesheet" href="https://unpkg.com/@ouvarov/scroll-parallax/style.css">

Then use the classes directly — no build step, no JavaScript:

<!-- drifts +60px → -60px as it crosses the viewport -->
<div class="ouvarov-parallax" style="--ouvarov-parallax-from: 60px; --ouvarov-parallax-to: -60px">
  <img src="/hero.jpg" alt="">
</div>

<!-- bare class uses the defaults (+30px → -30px) -->
<div class="ouvarov-parallax"><img src="/hero.jpg" alt=""></div>

<!-- horizontal drift -->
<div class="ouvarov-parallax" data-axis="x"></div>

<!-- page reading bar, pinned to the top -->
<div class="ouvarov-scroll-progress" data-position="top"></div>

<!-- sticky header that shrinks 80px → 56px over 200px of scroll -->
<header class="ouvarov-sticky-shrink"></header>

These are the same class names and --ouvarov-* custom properties the React components emit — read style.css for the full set. The React layer is just typed sugar that sets them for you.

Scope

One thing, done well: translate, opacity, scale and rotate on scroll. No spring physics, no gesture handling, no layout animations — by design.

  • The <Parallax> wrapper adds one DOM node. Don't want it? Skip the component and apply the .ouvarov-parallax class with the custom properties directly — the animation is pure CSS, no React required (see Use without React).

License

MIT © Olexandr Uvarov

About

Scroll-driven animation for React in ~350 bytes. Pure CSS animation-timeline: view().

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages