ScrollScroll Animationsno dependencies

ParallaxLayers

A self-contained smooth-scroll scene where up to five decorative planes (glow orbs, rings, glass cards, dots and sparks) translate at different fractions of the scroll distance behind native-speed headlines, creating layered depth.

Preview

Parallax Layers

Depth in motion

Five planes drift at five speeds. Scroll inside to feel the space open up.

Back layers crawl.
Front layers race.

Your eye reads the difference
as distance.

Customize

10 props
5
1
0.1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add parallax-layers

Writes components/ui/parallax-layers.tsx and components/styles/parallax-layers.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { ParallaxLayers } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ParallaxLayers />6  );7}

Props

PropTypeDefault
title

Headline shown on the first screen.

string'Depth in motion'
subtitle

Supporting line under the headline.

string'Five planes drift at five speeds. Scroll inside to feel the space open up.'
layers

Number of depth layers (2–5), sampled from back to front.

number5
depth

Multiplier for the speed difference between layers (0 disables parallax).

number1
color1

Primary layer color.

string'#a78bfa'
color2

Secondary layer color.

string'#38bdf8'
color3

Tertiary layer color.

string'#f472b6'
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

The viewport is a focusable role="region" with an aria-label, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native, unsmoothed) and see a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the top/bottom edge wheel events are released so the page keeps scrolling. Touch and scrollbar dragging stay native. Under prefers-reduced-motion (or smooth=false) the wheel is not intercepted at all. All parallax shapes are aria-hidden; only the headline and statements are exposed. Under prefers-reduced-motion the layers stay static.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/parallax-layers.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/parallax-layers.module.css";5 6interface SmoothScrollState {7  /** Current scrollTop of the viewport in px */8  scroll: number;9  /** Maximum scrollTop in px */10  limit: number;11  /** scroll / limit, 0..1 */12  progress: number;13  /** Signed scroll velocity in px per 60fps frame */14  velocity: number;15}16 17interface SmoothScrollOptions {18  lerp: number;19  wheelMultiplier: number;20  smooth: boolean;21  onScroll?: (state: SmoothScrollState) => void;22}23 24const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));25 26function usePrefersReducedMotion(): boolean {27  const [reduced, setReduced] = React.useState(false);28  React.useEffect(() => {29    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");30    const update = () => setReduced(mq.matches);31    update();32    mq.addEventListener("change", update);33    return () => mq.removeEventListener("change", update);34  }, []);35  return reduced;36}37 38/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */39function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {40  let el: Element | null = node instanceof Element ? node : null;41  while (el && el !== root) {42    if (el instanceof HTMLElement) {43      if (el.dataset.mfScrollPrevent !== undefined) return true;44      const overflowY = window.getComputedStyle(el).overflowY;45      if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {46        const canMove = dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1;47        if (canMove) return true;48      }49    }50    el = el.parentElement;51  }52  return false;53}54 55/**56 * Self-contained smooth scroller for an overflow container. Wheel input updates a target57 * position; a rAF loop eases the real scrollTop toward it (frame-rate independent lerp) and58 * stops once settled. Touch, keyboard and scrollbar drags stay native. At the top/bottom edge59 * wheel events are released so the page keeps scrolling.60 */61function useSmoothScroll(62  containerRef: React.RefObject<HTMLElement | null>,63  { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,64): React.RefObject<SmoothScrollState> {65  const stateRef = React.useRef<SmoothScrollState>({ scroll: 0, limit: 0, progress: 0, velocity: 0 });66  const callbackRef = React.useRef(onScroll);67  React.useEffect(() => {68    callbackRef.current = onScroll;69  }, [onScroll]);70 71  React.useEffect(() => {72    const el = containerRef.current;73    if (!el) return;74    const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);75    const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;76    let target = el.scrollTop;77    let current = target;78    let raf = 0;79    let lastTime = 0;80    let lastNative = el.scrollTop;81    let lastNativeTime = 0;82    let settleTimer: ReturnType<typeof setTimeout> | undefined;83 84    const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);85 86    const emit = (velocity: number) => {87      const limit = getLimit();88      const scroll = clamp(el.scrollTop, 0, limit);89      lastNative = el.scrollTop;90      const s = stateRef.current;91      s.scroll = scroll;92      s.limit = limit;93      s.progress = limit > 0 ? scroll / limit : 0;94      s.velocity = velocity;95      callbackRef.current?.(s);96    };97 98    const tick = (time: number) => {99      const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;100      lastTime = time;101      const limit = getLimit();102      target = clamp(target, 0, limit);103      if (Math.abs(el.scrollTop - current) > 2) {104        // Something else moved the viewport (scrollbar drag, keyboard, touch): hand control back.105        current = target = el.scrollTop;106        raf = 0;107        lastTime = 0;108        emit(0);109        return;110      }111      const prev = current;112      const frames = dt / 16.667;113      current += (target - current) * (1 - Math.pow(1 - ease, frames));114      if (Math.abs(target - current) < 0.35) current = target;115      el.scrollTop = current;116      emit((current - prev) / frames);117      if (current === target) {118        raf = 0;119        lastTime = 0;120        emit(0);121        return;122      }123      raf = requestAnimationFrame(tick);124    };125 126    const onWheel = (event: WheelEvent) => {127      if (event.ctrlKey || event.defaultPrevented) return;128      if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;129      let dy = event.deltaY;130      if (event.deltaMode === 1) dy *= 16;131      else if (event.deltaMode === 2) dy *= el.clientHeight;132      dy *= multiplier;133      if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;134      const limit = getLimit();135      if (!raf) current = target = el.scrollTop;136      // Release to native scrolling at the edges so the page is never trapped.137      if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;138      event.preventDefault();139      target = clamp(target + dy, 0, limit);140      if (!raf) {141        lastTime = 0;142        raf = requestAnimationFrame(tick);143      }144    };145 146    const onNativeScroll = () => {147      if (raf) return;148      const top = el.scrollTop;149      const delta = top - lastNative;150      if (delta === 0) return;151      const now = performance.now();152      const dt = lastNativeTime ? clamp(now - lastNativeTime, 8, 100) : 16.667;153      lastNativeTime = now;154      current = target = top;155      emit(delta / (dt / 16.667));156      if (settleTimer) clearTimeout(settleTimer);157      settleTimer = setTimeout(() => {158        lastNativeTime = 0;159        emit(0);160      }, 90);161    };162 163    if (smooth) el.addEventListener("wheel", onWheel, { passive: false });164    el.addEventListener("scroll", onNativeScroll, { passive: true });165    const ro = new ResizeObserver(() => emit(stateRef.current.velocity));166    ro.observe(el);167    if (el.firstElementChild) ro.observe(el.firstElementChild);168    emit(0);169 170    return () => {171      if (smooth) el.removeEventListener("wheel", onWheel);172      el.removeEventListener("scroll", onNativeScroll);173      ro.disconnect();174      if (raf) cancelAnimationFrame(raf);175      if (settleTimer) clearTimeout(settleTimer);176    };177  }, [containerRef, lerp, wheelMultiplier, smooth]);178 179  return stateRef;180}181 182export interface ParallaxLayersProps {183  /** Headline shown on the first screen */184  title?: string;185  /** Supporting line under the headline */186  subtitle?: string;187  /** Number of depth layers (2–5) */188  layers?: number;189  /** Multiplier for the speed difference between layers */190  depth?: number;191  /** Primary layer color */192  color1?: string;193  /** Secondary layer color */194  color2?: string;195  /** Tertiary layer color */196  color3?: string;197  /** Viewport height in px */198  height?: number;199  /** Viewport corner radius in px */200  borderRadius?: number;201  /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */202  lerp?: number;203  /** Multiplier applied to wheel / trackpad deltas */204  wheelMultiplier?: number;205  /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */206  smooth?: boolean;207  /** Optional className merged onto the root */208  className?: string;209  /** Inline styles merged onto the root */210  style?: React.CSSProperties;211}212 213const rand = (n: number) => {214  const x = Math.sin(n * 9301 + 49297) * 233280;215  return x - Math.floor(x);216};217 218type LayerKind = "orb" | "ring" | "card" | "dot" | "spark";219 220/** Back-to-front layer recipes; speed is the fraction of the scroll distance a layer travels. */221const LAYERS: { kind: LayerKind; speed: number; count: number }[] = [222  { kind: "orb", speed: 0.12, count: 4 },223  { kind: "ring", speed: 0.32, count: 6 },224  { kind: "card", speed: 0.58, count: 6 },225  { kind: "dot", speed: 0.85, count: 14 },226  { kind: "spark", speed: 1.4, count: 9 },227];228 229const SCREENS = 3;230 231export function ParallaxLayers({232  title = "Depth in motion",233  subtitle = "Five planes drift at five speeds. Scroll inside to feel the space open up.",234  layers = 5,235  depth = 1,236  color1 = "#a78bfa",237  color2 = "#38bdf8",238  color3 = "#f472b6",239  height = 380,240  borderRadius = 20,241  lerp = 0.1,242  wheelMultiplier = 1,243  smooth = true,244  className = "",245  style,246}: ParallaxLayersProps) {247  const reduced = usePrefersReducedMotion();248  const rootRef = React.useRef<HTMLDivElement>(null);249  const viewportRef = React.useRef<HTMLDivElement>(null);250 251  const handleScroll = React.useCallback((state: SmoothScrollState) => {252    rootRef.current?.style.setProperty("--mf-pl-s", state.scroll.toFixed(2));253  }, []);254 255  useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });256 257  const count = clamp(Math.round(layers), 2, 5);258  const picked = Array.from({ length: count }, (_, i) => Math.round((i * (LAYERS.length - 1)) / (count - 1)));259  const colors = [color1, color2, color3];260  const safeDepth = Math.max(0, depth);261  const travel = height * (SCREENS - 1);262 263  const vars = {264    "--mf-pl-h": `${height}px`,265    "--mf-pl-r": `${borderRadius}px`,266    "--mf-pl-c1": color1,267    "--mf-pl-c2": color2,268    "--mf-pl-c3": color3,269    ...style,270  } as React.CSSProperties;271 272  return (273    <div ref={rootRef} style={vars} className={`${styles.root} ${className}`.trim()}>274      <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>275        <div className={styles.content}>276          <div aria-hidden="true" className={styles.stage}>277            {picked.map((layerIndex, order) => {278              const layer = LAYERS[layerIndex];279              const speed = layer.speed * safeDepth;280              const span = height + travel * speed;281              return (282                <div283                  key={layer.kind}284                  className={`${styles.layer} ${styles[layer.kind]}`}285                  style={{ "--mf-pl-f": speed.toFixed(3), zIndex: order } as React.CSSProperties}286                >287                  {Array.from({ length: layer.count }, (_, i) => {288                    const seed = layerIndex * 97 + i * 13;289                    const top = ((i + rand(seed)) / layer.count) * span - height * 0.08;290                    const left = rand(seed + 1) * 92;291                    const size = 0.6 + rand(seed + 2) * 0.8;292                    return (293                      <span294                        key={i}295                        className={styles.item}296                        style={297                          {298                            top: `${top.toFixed(1)}px`,299                            left: `${left.toFixed(2)}%`,300                            "--mf-pl-size": size.toFixed(3),301                            "--mf-pl-c": colors[(i + layerIndex) % 3],302                            "--mf-pl-rot": `${Math.round(rand(seed + 3) * 40 - 20)}deg`,303                          } as React.CSSProperties304                        }305                      />306                    );307                  })}308                </div>309              );310            })}311          </div>312          <section className={styles.screen}>313            <span className={styles.kicker}>Parallax Layers</span>314            <h3 className={styles.title}>{title}</h3>315            <p className={styles.subtitle}>{subtitle}</p>316          </section>317          <section className={styles.screen}>318            <p className={styles.statement}>319              Back layers crawl.320              <br />321              <span className={styles.accentText}>Front layers race.</span>322            </p>323          </section>324          <section className={styles.screen}>325            <p className={styles.statement}>326              Your eye reads the difference327              <br />328              as <span className={styles.accentText}>distance</span>.329            </p>330          </section>331        </div>332      </div>333    </div>334  );335}