ScrollScroll Animationsno dependencies

ParallaxColumns

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

Parallax Columns

A gallery that moves with you

Customize

9 props
6
12
0.8
0.1
1

Installation

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

npx motionfoundry add parallax-columns

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

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

Props

PropTypeDefault
title

Heading on the frosted card over the columns; an empty string hides the card.

string'A gallery that moves with you'
tilesPerColumn

Number of gradient tiles in each of the three columns.

number6
gap

Gap in px between columns and tiles.

number12
speed

Travel speed of the outer columns as a fraction (0..1) of their available distance; the right column moves at 55% of the left.

number0.8
counterMiddle

Move the middle column in the opposite direction (downward) as you scroll.

booleantrue
palette

Colors cycled through the tile gradients.

string[]['#f472b6', '#a78bfa', '#60a5fa', '#2dd4bf', '#fbbf24', '#fb7185']
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" 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

components/ui/parallax-columns.tsx
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}