Skip to content

Components / Scroll

Parallax

An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll. Zero JavaScript: a translate bound to the element's own view timeline. Stack a few with different speeds for depth.

Live demo

speed 0.45

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 115 · not Firefox · Safari 26; needs scroll-timeline. Elsewhere: the element stays in place (Firefox).

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Parallax from '@moonarc/core/Parallax';
---
<div class="relative">
  <Parallax speed={-0.2} class="absolute inset-0"><img src="/bg.jpg" alt="" /></Parallax>
  <Parallax speed={0.4}><h2 class="relative">Foreground moves faster</h2></Parallax>
</div>

<!-- sideways: clip the row, or the drift widens the page and it scrolls horizontally -->
<div class="overflow-x-clip">
  <Parallax axis="x" speed={0.2}><p class="text-6xl">Moving type</p></Parallax>
</div>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/parallax.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

Parallax.astro
---
/**
 * Parallax — an element drifts at its own speed as it crosses the viewport.
 *
 * animation-timeline: view() binds a translate to the element's own passage
 * through the scrollport, so the drift is exact, never lags, and costs no
 * scroll listener. Browsers without scroll timelines show it in place.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Drift as a fraction of the viewport, −1…1. Negative moves against the scroll. */
  speed?: number;
  /** Axis of travel. */
  axis?: 'y' | 'x';
}

const { as: Tag = 'div', speed = 0.3, axis = 'y', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-px-speed:${speed}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-parallax', className]} data-axis={axis === 'x' ? 'x' : undefined} style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    @supports (animation-timeline: view()) {
      :where(.ma-parallax) {
        will-change: translate;
        animation: ma-parallax-y linear both;
      }
      :where(.ma-parallax[data-axis='x']) {
        animation-name: ma-parallax-x;
      }
      /* Separate rule on purpose: lightningcss folds animation-timeline into
         the shorthand, which browsers reject. Distinct selector text keeps them apart. */
      :where(html) :where(.ma-parallax) {
        animation-timeline: view();
        animation-range: cover 0% cover 100%;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-parallax) {
        animation: none;
        will-change: auto;
      }
    }
  }

  @keyframes ma-parallax-y {
    from {
      translate: 0 calc(var(--ma-px-speed, 0.3) * 50vh);
    }
    to {
      translate: 0 calc(var(--ma-px-speed, 0.3) * -50vh);
    }
  }
  @keyframes ma-parallax-x {
    from {
      translate: calc(var(--ma-px-speed, 0.3) * 50vw) 0;
    }
    to {
      translate: calc(var(--ma-px-speed, 0.3) * -50vw) 0;
    }
  }
</style>

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
speednumber0.3Drift over the element's full passage, as a fraction of the viewport: 0.3 travels 30 vh. Negative moves against the scroll. Keep |speed| under 0.5 or the element visits places it should not.
axis'y' | 'x''y'Axis of travel. For x, give the parent overflow-x: clip: a translated element still counts toward the page's scrollable width, so the drift would add a horizontal scrollbar.

Reduced motion

No drift; the element scrolls with the page.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: react-scroll-parallax · Rellax · GSAP ScrollTrigger for simple parallax. See the migration table.

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Bind translate, scale, rotate and opacity to the scroll position: the element goes from the start values you give to neutral across a range of its own passage through the viewport, exactly in step.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown