Three masonry columns of gradient tiles pinned inside their own smooth-scroll viewport; scrolling drives each column at a different speed while the middle column counter-moves, under a frosted title card.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add parallax-columnsWrites components/ui/parallax-columns.tsx and components/styles/parallax-columns.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ParallaxColumns } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ParallaxColumns />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrow/Page keys/Space (native, unsmoothed) with a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move; at the edges wheel events are released to the page, and touch and scrollbar dragging stay native. The tile columns are decorative and aria-hidden; the title is a real heading. Under prefers-reduced-motion the wheel is not intercepted and the columns stay still.
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-columns.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}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 if (el.dataset.mfScrollPrevent !== undefined) return true;42 const oy = window.getComputedStyle(el).overflowY;43 if ((oy === "auto" || oy === "scroll") && el.scrollHeight > el.clientHeight + 1) {44 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;45 }46 }47 el = el.parentElement;48 }49 return false;50}51 52/**53 * Self-contained smooth scroller for an overflow container: wheel input sets a target, a rAF54 * loop eases scrollTop toward it (frame-rate independent) and stops when settled. Touch,55 * keyboard and scrollbar stay native; at the edges wheel events are released to the page.56 */57function useSmoothScroll(58 containerRef: React.RefObject<HTMLElement | null>,59 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,60) {61 const callbackRef = React.useRef(onScroll);62 React.useEffect(() => {63 callbackRef.current = onScroll;64 }, [onScroll]);65 66 React.useEffect(() => {67 const el = containerRef.current;68 if (!el) return;69 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);70 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;71 let target = el.scrollTop;72 let current = target;73 let raf = 0;74 let lastTime = 0;75 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);76 const emit = () => {77 const limit = getLimit();78 const scroll = clamp(el.scrollTop, 0, limit);79 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });80 };81 const stop = () => {82 raf = 0;83 lastTime = 0;84 };85 const tick = (time: number) => {86 const frames = (lastTime ? clamp(time - lastTime, 1, 64) : 16.667) / 16.667;87 lastTime = time;88 target = clamp(target, 0, getLimit());89 if (Math.abs(el.scrollTop - current) > 2) {90 // Scrollbar drag, keyboard or touch moved the viewport: hand control back.91 current = target = el.scrollTop;92 stop();93 emit();94 return;95 }96 current += (target - current) * (1 - Math.pow(1 - ease, frames));97 if (Math.abs(target - current) < 0.35) current = target;98 el.scrollTop = current;99 emit();100 if (current === target) stop();101 else raf = requestAnimationFrame(tick);102 };103 const onWheel = (event: WheelEvent) => {104 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;105 let dy = event.deltaY;106 if (event.deltaMode === 1) dy *= 16;107 else if (event.deltaMode === 2) dy *= el.clientHeight;108 dy *= multiplier;109 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;110 const limit = getLimit();111 if (!raf) current = target = el.scrollTop;112 // Release to native scrolling at the edges so the page is never trapped.113 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;114 event.preventDefault();115 target = clamp(target + dy, 0, limit);116 if (!raf) raf = requestAnimationFrame(tick);117 };118 const onNativeScroll = () => {119 if (raf) return;120 current = target = el.scrollTop;121 emit();122 };123 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });124 el.addEventListener("scroll", onNativeScroll, { passive: true });125 const ro = new ResizeObserver(emit);126 ro.observe(el);127 if (el.firstElementChild) ro.observe(el.firstElementChild);128 emit();129 return () => {130 if (smooth) el.removeEventListener("wheel", onWheel);131 el.removeEventListener("scroll", onNativeScroll);132 ro.disconnect();133 if (raf) cancelAnimationFrame(raf);134 };135 }, [containerRef, lerp, wheelMultiplier, smooth]);136}137 138export interface ParallaxColumnsProps {139 /** Heading shown on the frosted card over the columns (empty string hides it) */140 title?: string;141 /** Number of tiles in each of the three columns */142 tilesPerColumn?: number;143 /** Gap in px between columns and tiles */144 gap?: number;145 /** Travel speed of the outer columns, 0..1 of their available distance */146 speed?: number;147 /** Move the middle column in the opposite direction */148 counterMiddle?: boolean;149 /** Gradient colors cycled through the tiles */150 palette?: string[];151 /** Viewport height in px */152 height?: number;153 /** Viewport corner radius in px */154 borderRadius?: number;155 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */156 lerp?: number;157 /** Multiplier applied to wheel / trackpad deltas */158 wheelMultiplier?: number;159 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */160 smooth?: boolean;161 /** Optional className merged onto the root */162 className?: string;163 /** Inline styles merged onto the root */164 style?: React.CSSProperties;165}166 167const DEFAULT_PALETTE = ["#f472b6", "#a78bfa", "#60a5fa", "#2dd4bf", "#fbbf24", "#fb7185"];168const LABELS = ["Aurora", "Dune", "Lagoon", "Ember", "Nebula", "Orchard", "Glacier", "Saffron", "Tidal", "Velvet"];169 170const rand = (n: number) => {171 const x = Math.sin(n * 9301 + 49297) * 233280;172 return x - Math.floor(x);173};174 175export function ParallaxColumns({176 title = "A gallery that moves with you",177 tilesPerColumn = 6,178 gap = 12,179 speed = 0.8,180 counterMiddle = true,181 palette = DEFAULT_PALETTE,182 height = 380,183 borderRadius = 20,184 lerp = 0.1,185 wheelMultiplier = 1,186 smooth = true,187 className = "",188 style,189}: ParallaxColumnsProps) {190 const reduced = usePrefersReducedMotion();191 const viewportRef = React.useRef<HTMLDivElement>(null);192 const stageRef = React.useRef<HTMLDivElement>(null);193 const colRefs = React.useRef<(HTMLDivElement | null)[]>([]);194 const travelRef = React.useRef<number[]>([0, 0, 0]);195 const progressRef = React.useRef(0);196 const colors = palette.length > 0 ? palette : DEFAULT_PALETTE;197 const count = Math.max(2, Math.round(tilesPerColumn));198 199 const apply = React.useCallback(() => {200 const p = progressRef.current;201 const k = clamp(speed, 0, 1);202 const factors = [p * k, counterMiddle ? 1 - p : p, p * k * 0.55];203 for (let c = 0; c < 3; c++) {204 const col = colRefs.current[c];205 if (!col) continue;206 const y = reduced ? 0 : -factors[c] * travelRef.current[c];207 col.style.transform = `translate3d(0, ${y.toFixed(2)}px, 0)`;208 }209 }, [speed, counterMiddle, reduced]);210 211 const handleScroll = React.useCallback(212 ({ progress }: SmoothScrollState) => {213 progressRef.current = progress;214 apply();215 },216 [apply],217 );218 219 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });220 221 React.useEffect(() => {222 const stage = stageRef.current;223 if (!stage) return;224 const measure = () => {225 const h = stage.clientHeight;226 travelRef.current = colRefs.current.map((col) => (col ? Math.max(0, col.offsetHeight - h) : 0));227 apply();228 };229 measure();230 const ro = new ResizeObserver(measure);231 ro.observe(stage);232 colRefs.current.forEach((col) => col && ro.observe(col));233 return () => ro.disconnect();234 }, [apply, count, gap]);235 236 const vars = {237 "--mf-pcl-h": `${height}px`,238 "--mf-pcl-r": `${borderRadius}px`,239 "--mf-pcl-gap": `${Math.max(0, gap)}px`,240 ...style,241 } as React.CSSProperties;242 243 return (244 <div style={vars} className={`${styles.root} ${className}`.trim()}>245 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title || "Parallax gallery"}>246 <div style={{ height: height * 3 }}>247 <div ref={stageRef} className={styles.stage}>248 {[0, 1, 2].map((c) => (249 <div key={c} className={styles.lane} aria-hidden="true">250 <div251 ref={(node) => {252 colRefs.current[c] = node;253 }}254 className={styles.column}255 >256 {Array.from({ length: count }, (_, t) => {257 const seed = c * 41 + t * 7;258 const c1 = colors[(c + t * 2) % colors.length];259 const c2 = colors[(c + t * 2 + 1) % colors.length];260 return (261 <div262 key={t}263 className={styles.tile}264 style={265 {266 height: Math.round(height * (0.3 + rand(seed) * 0.32)),267 "--mf-pcl-c1": c1,268 "--mf-pcl-c2": c2,269 "--mf-pcl-a": `${Math.round(110 + rand(seed + 3) * 140)}deg`,270 "--mf-pcl-gx": `${Math.round(15 + rand(seed + 5) * 70)}%`,271 } as React.CSSProperties272 }273 >274 {rand(seed + 9) > 0.45 && <span className={styles.label}>{LABELS[(seed + t) % LABELS.length]}</span>}275 </div>276 );277 })}278 </div>279 </div>280 ))}281 {title && (282 <div className={styles.overlay}>283 <div className={styles.card}>284 <span className={styles.kicker}>Parallax Columns</span>285 <h3 className={styles.title}>{title}</h3>286 </div>287 </div>288 )}289 </div>290 </div>291 </div>292 </div>293 );294}