---
title: Parallax
description: Layered depth on scroll, tight and controlled.
group: Scroll
order: 23
new: true
---

A long scroll of cards where the images drift slower than the copy. Keep the speeds small so the depth stays quiet.

```tsx
import { Parallax, ParallaxLayer } from "@/components/motion/parallax"

export function Example() {
  return (
    <Parallax strength={1} className="h-96 w-full">
      <div className="flex flex-col gap-6 px-4 py-8">
        <article className="overflow-hidden rounded-xl border">
          <div className="relative aspect-[16/10] overflow-hidden">
            <ParallaxLayer speed={0.45} className="absolute inset-0">
              <img src="/photo.jpg" alt="" className="size-full object-cover" />
            </ParallaxLayer>
          </div>
          <div className="px-4 py-4">
            <h3>Token ready</h3>
            <p>Images lag behind the scroll.</p>
          </div>
        </article>
      </div>
    </Parallax>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add parallax
```

## Usage

```tsx
import { Parallax, ParallaxLayer } from "@/components/motion/parallax"
```

```tsx
<Parallax strength={1} className="h-96 w-full">
  <div className="flex flex-col gap-6 px-4 py-8">
    <article className="overflow-hidden rounded-xl border">
      <div className="relative aspect-[16/10] overflow-hidden">
        <ParallaxLayer speed={0.45} className="absolute inset-0">
          <img src="/photo.jpg" alt="" className="size-full object-cover" />
        </ParallaxLayer>
      </div>
      <div className="px-4 py-4">
        <h3>Token ready</h3>
        <p>Images lag behind the scroll.</p>
      </div>
    </article>
  </div>
</Parallax>
```

## API Reference

### Parallax

| Prop | Type | Default |
| --- | --- | --- |
| strength | number | 1 |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes. Scroll happens on this element.

### ParallaxLayer

| Prop | Type | Default |
| --- | --- | --- |
| speed | number | 0.35 |
| className | string | — |
| children | ReactNode | — |

Put the layer inside an `overflow-hidden` frame. The layer scales itself so the image always covers while it drifts.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

type ParallaxContextValue = {
  root: HTMLDivElement | null;
  tick: number;
  strength: number;
};

const ParallaxContext = createContext<ParallaxContextValue | null>(null);

/** Converts scroll offset into pixel drift. */
const DRIFT = 0.22;

export type ParallaxProps = ComponentProps<"div"> & {
  /** Overall depth multiplier. */
  strength?: number;
  children?: ReactNode;
};

export type ParallaxLayerProps = ComponentProps<"div"> & {
  /**
   * How much this layer lags the scroll.
   * Higher = more image drift inside its frame.
   */
  speed?: number;
  children?: ReactNode;
};

function Parallax({
  className,
  strength = 1,
  children,
  style,
  onScroll,
  ...props
}: ParallaxProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [tick, setTick] = useState(0);
  const [root, setRoot] = useState<HTMLDivElement | null>(null);
  const pull = Math.max(strength, 0);

  useEffect(() => {
    const el = ref.current;
    setRoot(el);
    const id = window.requestAnimationFrame(() => setTick((n) => n + 1));
    const observer = el
      ? new ResizeObserver(() => setTick((n) => n + 1))
      : null;
    if (el) observer?.observe(el);
    return () => {
      window.cancelAnimationFrame(id);
      observer?.disconnect();
    };
  }, []);

  function bump() {
    setTick((n) => n + 1);
  }

  return (
    <ParallaxContext.Provider value={{ root, tick, strength: pull }}>
      <div
        ref={ref}
        data-slot="parallax"
        className={cn("arctis-parallax relative overflow-y-auto", className)}
        style={style as CSSProperties}
        {...props}
        onScroll={(event) => {
          onScroll?.(event);
          bump();
        }}
      >
        {children}
      </div>
    </ParallaxContext.Provider>
  );
}

function ParallaxLayer({
  className,
  speed = 0.35,
  children,
  style,
  ...props
}: ParallaxLayerProps) {
  const ctx = useContext(ParallaxContext);
  const frameRef = useRef<HTMLDivElement>(null);
  const [motion, setMotion] = useState({ y: 0, scale: 1 });

  useEffect(() => {
    const root = ctx?.root;
    const frame = frameRef.current;
    if (!root || !frame) return;

    if (
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      setMotion({ y: 0, scale: 1 });
      return;
    }

    const rootRect = root.getBoundingClientRect();
    // Measure the untransformed frame so drift does not feed back into itself.
    const frameRect = frame.getBoundingClientRect();
    const rootCenter = rootRect.top + rootRect.height / 2;
    const frameCenter = frameRect.top + frameRect.height / 2;
    const delta = frameCenter - rootCenter;
    const factor = speed * (ctx?.strength ?? 1);
    const y = delta * factor * -DRIFT;

    // Scale past the max possible drift so the image never shows the frame behind.
    const maxAbsY = (rootRect.height / 2) * Math.abs(factor) * DRIFT;
    const frameH = Math.max(frame.clientHeight || frameRect.height, 1);
    const scale = 1 + (2 * maxAbsY) / frameH + 0.04;

    setMotion({ y, scale });
  }, [ctx?.root, ctx?.tick, ctx?.strength, speed]);

  return (
    <div
      ref={frameRef}
      data-slot="parallax-layer"
      className={cn("arctis-parallax-layer", className)}
      style={style as CSSProperties}
      {...props}
    >
      <div
        className="arctis-parallax-layer-shift size-full will-change-transform"
        style={{
          transform: `translate3d(0, ${motion.y}px, 0) scale(${motion.scale})`,
        }}
      >
        {children}
      </div>
    </div>
  );
}

export { Parallax, ParallaxLayer };
```
