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
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add parallax-layersWrites 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.
1import { ParallaxLayers } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ParallaxLayers />6 );7}Props
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
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}