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
| Prop | Type | Default | Description |
|---|---|---|---|
| range | number | 0.12 | Furthest any layer travels from rest, as a fraction of the frame height. |
| depth | number | 0 | On ParallaxLayer: 0 rides with the frame, 1 moves the full range with the scroll, -1 against it. |
| children | ReactNode | What 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.