Parallax <mv-parallax>

Layered scroll depth with zero setup: tag any descendant with data-speed and it drifts at its own pace as the section crosses its scroller, moved by a native view timeline off the main thread, with a light visible-only JS fallback.

CategoryScroll
TypeWeb Component (<mv-parallax>)
Statusstable
Keywordssafe-rewrite, parallax, layers, hero, depth, gallery, view-timeline, scroll-driven

When to use

  • A hero or gallery section should gain depth by moving background and foreground layers at different speeds
  • Decorative shapes, words or images should drift horizontally or vertically as a section crosses the viewport
  • Parallax is needed inside a scrollable panel, not only on the page itself

Avoid when

  • Elements should simply fade or slide in once when they appear → use Scroll Reveal instead
  • The pointer, not the scroll, should drive layer depth on a card → use Tilt Card instead
  • Many large layered sections stack up on one page; strong motion there can cause discomfort

Install

node scripts/add.mjs parallax --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["parallax"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/element.js, core/motion.js, components/parallax/parallax.js, components/parallax/parallax.css.

Usage

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div id="mv-parallax-demo" class="mv-scroll-area" data-orientation="vertical" tabindex="0" aria-label="Parallax demo, scroll to explore">
  <style>
    #mv-parallax-demo { width: min(100%, 52rem); height: 440px; margin-inline: auto; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-bg); }
    #mv-parallax-demo .copy { padding: 2.5rem 1.5rem; text-align: center; }
    #mv-parallax-demo .copy p { margin: .5rem auto 0; max-width: 30rem; color: var(--mv-fg-muted); font-size: .92rem; line-height: 1.55; }
    #mv-parallax-demo .kicker { margin: 0; font-size: .72rem; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: var(--mv-accent-fg); }
    #mv-parallax-demo h2 { margin: .4rem 0 0; font: 700 1.6rem/1.15 var(--mv-font-display); letter-spacing: -.03em; }

    /* 1 · Layered scene */
    #mv-parallax-demo .scene { height: 440px; margin: 0 1rem; border-radius: var(--mv-radius-xl); color: oklch(.98 .01 80); background: linear-gradient(180deg, oklch(.2 .05 275) 0%, oklch(.34 .09 300) 45%, oklch(.66 .14 40) 78%, oklch(.8 .12 65) 100%); }
    #mv-parallax-demo .fill { position: absolute; inset-inline: 0; pointer-events: none; }
    #mv-parallax-demo .fill svg { display: block; width: 100%; height: 100%; }
    #mv-parallax-demo .sky { top: 0; height: 70%; background-image: radial-gradient(2px 2px at 8% 30%, #fffc, transparent), radial-gradient(1.5px 1.5px at 19% 62%, #fff9, transparent), radial-gradient(2px 2px at 33% 18%, #fffb, transparent), radial-gradient(1.5px 1.5px at 47% 44%, #fff8, transparent), radial-gradient(2px 2px at 61% 26%, #fffa, transparent), radial-gradient(1.5px 1.5px at 74% 58%, #fff8, transparent), radial-gradient(2px 2px at 86% 16%, #fffc, transparent), radial-gradient(1.5px 1.5px at 94% 40%, #fff9, transparent); }
    #mv-parallax-demo .moon { position: absolute; top: 16%; left: 16%; width: 4.5rem; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 36% 34%, oklch(.99 .02 90), oklch(.9 .05 85) 60%, oklch(.82 .06 80)); box-shadow: 0 0 60px 12px oklch(.95 .06 85 / .35); }
    #mv-parallax-demo .heading { position: absolute; inset: 22% 1rem auto; text-align: center; text-shadow: 0 8px 32px oklch(.15 .05 290 / .5); }
    #mv-parallax-demo .heading p { margin: 0; font-size: .72rem; letter-spacing: .32em; text-transform: uppercase; opacity: .85; }
    #mv-parallax-demo .heading h3 { margin: .35rem 0 0; font: 800 clamp(2.2rem, 7vw, 4rem)/.95 var(--mv-font-display); letter-spacing: -.045em; }
    #mv-parallax-demo .far { top: 52%; bottom: -20%; color: oklch(.5 .09 330); }
    #mv-parallax-demo .near { top: 64%; bottom: -20%; color: oklch(.36 .08 310); }
    #mv-parallax-demo .ground { top: 80%; bottom: -1px; color: oklch(.2 .04 290); }
    #mv-parallax-demo .chip { position: absolute; right: 1.25rem; bottom: 1.25rem; padding: .45rem .85rem; border-radius: var(--mv-radius-full); background: oklch(1 0 0 / .14); border: 1px solid oklch(1 0 0 / .25); backdrop-filter: blur(8px); font-size: .75rem; font-weight: 500; }

    /* 2 · Horizontal drift */
    #mv-parallax-demo .drift { display: grid; gap: .25rem; padding: 2.5rem 0; }
    #mv-parallax-demo .drift p { margin: 0; white-space: nowrap; text-align: center; font: 800 clamp(2rem, 6vw, 3.4rem)/1.05 var(--mv-font-display); letter-spacing: -.04em; }
    #mv-parallax-demo .drift p + p { color: transparent; -webkit-text-stroke: 1px var(--mv-fg-muted); }

    /* 3 · Staggered columns */
    #mv-parallax-demo .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; padding: 2rem 1rem 3rem; }
    #mv-parallax-demo .stack { display: grid; gap: .75rem; align-content: start; }
    #mv-parallax-demo .stack:nth-child(2) { margin-top: 2.5rem; }
    #mv-parallax-demo figure { margin: 0; height: 140px; display: flex; align-items: flex-end; padding: .75rem; border-radius: var(--mv-radius-lg); color: white; font-size: .8rem; font-weight: 600; text-shadow: 0 1px 8px #0007; }
  </style>

  <div class="copy">
    <p class="kicker">Field notes</p>
    <h2>A night in the Atacama</h2>
    <p>Scroll the panel: every layer of the scene moves at its own pace.</p>
  </div>

  <mv-parallax class="scene">
    <div class="fill sky" data-speed="-0.4" aria-hidden="true"></div>
    <div class="moon" data-speed="-0.34" aria-hidden="true"></div>
    <div class="heading" data-speed="-0.26">
      <p>San Pedro de Atacama</p>
      <h3>Valley of the Moon</h3>
    </div>
    <div class="fill far" data-speed="-0.16" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none" fill="currentColor"><path d="M0 300V120C120 70 220 60 330 95S560 40 690 70 930 30 1040 80 1160 90 1200 70V300Z"/></svg></div>
    <div class="fill near" data-speed="-0.07" aria-hidden="true"><svg viewBox="0 0 1200 300" preserveAspectRatio="none" fill="currentColor"><path d="M0 300V110C150 60 260 120 400 90S640 30 780 85 1010 120 1200 60V300Z"/></svg></div>
    <div class="fill ground" aria-hidden="true"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" fill="currentColor"><path d="M0 120V50C180 20 330 60 520 40S860 10 1020 38 1150 44 1200 30V120Z"/></svg></div>
    <span class="chip" data-speed="0.12">7,900 ft · Clear skies</span>
  </mv-parallax>

  <mv-parallax class="drift">
    <p data-speed-x="0.35">Salt flats · Geysers · Lagoons</p>
    <p data-speed-x="-0.35" aria-hidden="true">Salt flats · Geysers · Lagoons</p>
  </mv-parallax>

  <mv-parallax class="grid" style="--mv-parallax-overflow: visible">
    <div class="stack" data-speed="0.1">
      <figure style="background: linear-gradient(160deg, oklch(.8 .1 60), oklch(.5 .14 35))">El Tatio Geysers</figure>
      <figure style="background: linear-gradient(160deg, oklch(.82 .08 210), oklch(.5 .1 240))">Laguna Cejar</figure>
      <figure style="background: linear-gradient(160deg, oklch(.78 .1 20), oklch(.45 .13 10))">Rainbow Valley</figure>
    </div>
    <div class="stack" data-speed="-0.1">
      <figure style="background: linear-gradient(160deg, oklch(.86 .04 250), oklch(.52 .08 270))">Salar de Atacama</figure>
      <figure style="background: linear-gradient(160deg, oklch(.76 .12 300), oklch(.42 .14 285))">Stargazing at 2 a.m.</figure>
      <figure style="background: linear-gradient(160deg, oklch(.84 .08 95), oklch(.55 .11 70))">Death Valley Dunes</figure>
    </div>
    <div class="stack" data-speed="0.1">
      <figure style="background: linear-gradient(160deg, oklch(.8 .09 180), oklch(.46 .1 200))">Miscanti Lagoon</figure>
      <figure style="background: linear-gradient(160deg, oklch(.8 .1 45), oklch(.5 .13 25))">Pukará de Quitor</figure>
      <figure style="background: linear-gradient(160deg, oklch(.78 .08 140), oklch(.45 .09 160))">Jere Valley</figure>
    </div>
  </mv-parallax>

  <div class="copy">
    <p class="kicker">End of the road</p>
    <p>With reduced motion turned on, every layer stays exactly where it was placed.</p>
  </div>
</div>

API

Attributes

NameTypeDefaultDescription
data-speednumber0On a descendant: relative vertical speed. Negative = slower than the scroll (background, e.g. -0.3), positive = faster (foreground). Every layer sits at its authored position when the section is centered.
data-speed-xnumber0On a descendant: horizontal drift proportional to the scroll.
scrollerselector | windowReference scroll container (forces the JS path). Default: the nearest scroll container, or the page.
fallbackbooleanForces the JavaScript fallback (tests, browsers without animation-timeline).

Content structure

NameDescription
[data-speed] / [data-speed-x]Any non-inline descendant (block, image, SVG). A layer runs its own translate animation: wrap it if it needs another animation or translate. Make background layers taller than the section: the maximum offset is |speed| × (section height + scrollport height) / 2.

CSS variables

NameDefaultDescription
--mv-parallax-intensity1Global speed multiplier.
--mv-parallax-overflowclipSection overflow (visible to let layers spill out).

Accessibility

Purely visual: DOM order, focus order and content never change. Mark decorative layers aria-hidden. Reduced motion (OS setting or data-motion="reduce"): no animation at all, layers stay at their authored position. No scroll hijacking and no wheel or touch listeners: the native path runs on a view timeline; the fallback uses one passive scroll listener, one frame at a time, only while the section is near the viewport.