Skip to content
goodtabs UI
BrowseBrowse components

Parallax layers

Stacked layers drift at different rates inside a frame, transform only and capped.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/parallax-layers.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/parallax-layers

Hairline rings and four ridgelines at different depths. Scroll and the near planes outrun the far ones.

Usage

import { ParallaxLayers, ParallaxLayer } from "@/components/ui/parallax-layers"

<ParallaxLayers className="h-96 rounded-lg border">
  <ParallaxLayer depth={-0.4}>
    <img src="/sky.jpg" alt="" className="size-full object-cover" />
  </ParallaxLayer>
  <ParallaxLayer depth={0.6}>
    <img src="/hills.png" alt="" className="size-full object-cover" />
  </ParallaxLayer>
</ParallaxLayers>

Props

PropTypeDefaultDescription
rangenumber0.12Furthest any layer travels from rest, as a fraction of the frame height.
depthnumber0On ParallaxLayer: 0 rides with the frame, 1 moves the full range with the scroll, -1 against it.
childrenReactNodeWhat sits on the layer, positioned inside it.

Source

parallax-layers/parallax-layers.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const MOTION = "(prefers-reduced-motion: no-preference)";

type ParallaxLayersProps = React.ComponentProps<"div"> & {
  /** Furthest any layer travels from rest, as a fraction of the frame height. */
  range?: number;
};

/**
 * A frame of stacked layers that drift at different rates as it crosses the window. Every layer
 * hangs past the frame by exactly the distance it can travel, so no edge ever shows, and each one
 * passes through its rest position when the frame is centred. Reduced motion is that rest position.
 */
function ParallaxLayers({ range = 0.12, className, style, children, ...props }: ParallaxLayersProps) {
  const root = React.useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(MOTION, () => {
        const frame = root.current;
        if (!frame) return;
        const timeline = gsap.timeline({
          defaults: { ease: "none", duration: 1 },
          scrollTrigger: {
            trigger: frame,
            start: "top bottom",
            end: "bottom top",
            scrub: true,
            invalidateOnRefresh: true,
          },
        });
        gsap.utils.toArray<HTMLElement>("[data-slot=parallax-layer]").forEach((layer) => {
          const travel = () => Number(layer.dataset.depth) * range * frame.offsetHeight;
          timeline.fromTo(layer, { y: travel }, { y: () => -travel() }, 0);
        });
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [range] },
  );

  return (
    <div
      ref={root}
      data-slot="parallax-layers"
      className={cn("relative isolate overflow-hidden", className)}
      style={{ "--parallax-range": range, ...style } as React.CSSProperties}
      {...props}
    >
      {children}
    </div>
  );
}

type ParallaxLayerProps = React.ComponentProps<"div"> & {
  /**
   * How far this layer travels: 0 rides with the frame, 1 moves the full range with the scroll,
   * -1 moves the full range against it. Values past 1 are capped.
   */
  depth?: number;
};

/** One plane. It fills the frame plus the distance it can travel, so it never shows an edge. */
function ParallaxLayer({ depth = 0, className, style, children, ...props }: ParallaxLayerProps) {
  const capped = Math.max(-1, Math.min(1, depth));

  return (
    <div
      data-slot="parallax-layer"
      data-depth={capped}
      className={cn(
        "absolute inset-x-0 top-[calc(var(--parallax-range)*var(--parallax-depth)*-100%)] bottom-[calc(var(--parallax-range)*var(--parallax-depth)*-100%)]",
        className,
      )}
      style={{ "--parallax-depth": Math.abs(capped), ...style } as React.CSSProperties}
      {...props}
    >
      {children}
    </div>
  );
}

export { ParallaxLayers, ParallaxLayer };

For AI agents

Hand your agent this page, the markdown at /components/parallax-layers/prompt.md, or the Copy AI prompt button above.

When to use

  • A hero or feature visual that needs a little depth
  • Layered illustrations and cut out images
  • A quiet way to reward scrolling without pinning anything

When not to use

  • On text people need to read while it moves
  • Across a whole page, because many moving planes read as noise
  • When any layer needs to line up exactly with another

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • The frame needs a height from its className. Layers are absolute and give it none
  • Layers paint in DOM order: far planes first, near planes last. Mark decorative layers aria-hidden
  • Under reduced motion every layer stays at its rest position, the same frame you get with the frame centred

The maths

  • A layer travels ±depth × range × H, so depth 0.5 in a 672px frame at 0.12 moves ±40px.
  • It overhangs top and bottom by the same amount, so travel can never reveal an edge, and |depth| is capped at 1.
  • The sweep runs from frame top at window bottom to frame bottom at window top, so y is 0 exactly when the frame is centred.