Parallax
Content that drifts against the page while the frame scrolls past.
From the Nitip template — see it in contextScroll the page — the photo drifts inside the frame.
import { Parallax } from "@/components/ui/parallax";
// Demo photo from Unsplash (free under the Unsplash License), loaded from its image CDN.
const image = "https://images.unsplash.com/photo-1491824989090-cc2d0b57eb0d?auto=format&fit=crop&w=1200&q=80";
export default function ParallaxDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Parallax speed={30} className="h-56 w-full rounded-lg">
<img src={image} alt="Rolling hills in morning mist" className="object-cover" />
</Parallax>
<p className="text-center text-xs text-muted-foreground">Scroll the page — the photo drifts inside the frame.</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/parallax.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/parallax.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Parallax — content that drifts against the page while the frame scrolls past (from the Nitip "Parallax" image band). * Put an image, a video or anything else inside; the frame keeps its own size and clips the oversized layer. * The transform is written straight to the element in a passive scroll listener, as Framer does, so it never lags a * frame behind and never goes stale when images load late. * * Deviation from Framer: Framer ties the shift to the whole page scroll, which drifts further the longer the page is. * Here the shift is bounded to one pass of the frame through the viewport (`speed` % of the frame height), so the * effect is the same at any page length and no gap can appear. Static with reduced motion. */ export type ParallaxProps = { children: React.ReactNode; /** How far the content travels over one pass, as a percentage of the frame height. */ speed?: number; direction?: "vertical" | "horizontal"; className?: string; }; export function Parallax({ children, speed = 20, direction = "vertical", className }: ParallaxProps) { const frame = React.useRef<HTMLDivElement>(null); const layer = React.useRef<HTMLDivElement>(null); React.useEffect(() => { const frameEl = frame.current; const layerEl = layer.current; if (!frameEl || !layerEl) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { layerEl.style.transform = ""; return; } let distance = 0; const update = () => { const rect = frameEl.getBoundingClientRect(); const span = window.innerHeight + rect.height; /* 0 as the frame enters at the bottom, 1 as it leaves at the top. */ const progress = span ? Math.min(1, Math.max(0, (window.innerHeight - rect.top) / span)) : 0; const shift = (0.5 - progress) * distance; layerEl.style.transform = direction === "vertical" ? `translate3d(0, ${shift}px, 0)` : `translate3d(${shift}px, 0, 0)`; }; const measure = () => { const rect = frameEl.getBoundingClientRect(); distance = ((direction === "vertical" ? rect.height : rect.width) * speed) / 100; update(); }; measure(); const observer = new ResizeObserver(measure); observer.observe(frameEl); window.addEventListener("resize", measure); window.addEventListener("scroll", update, { passive: true }); return () => { observer.disconnect(); window.removeEventListener("resize", measure); window.removeEventListener("scroll", update); }; }, [speed, direction]); /* The layer is oversized by `speed` so its edges never come into view. */ const inset = direction === "vertical" ? { top: `-${speed / 2}%`, bottom: `-${speed / 2}%`, left: 0, right: 0 } : { left: `-${speed / 2}%`, right: `-${speed / 2}%`, top: 0, bottom: 0 }; return ( <div ref={frame} className={cn("relative overflow-hidden", className)}> <div ref={layer} style={{ position: "absolute", ...inset }} className="will-change-transform *:size-full"> {children} </div> </div> ); }
Usage
import { Parallax } from "@/components/ui/parallax";
<Parallax speed={30} className="h-[60vh] w-full">
<Image src={photo} alt="" fill className="object-cover" />
</Parallax>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | What drifts. The frame clips it and the layer is oversized so no edge shows. |
| speed | number | 20 | How far the content travels over one pass, as a percentage of the frame height. |
| direction | "vertical" | "horizontal" | "vertical" | Axis of the drift. |
| className | string | — | Sizes the frame — always give it a height. |
- Framer ties the shift to the whole page scroll, which drifts further the longer the page is; here it is bounded to one pass of the frame through the viewport, so no gap can appear.
- The transform is written in a passive scroll listener, so it never lags a frame behind. Static with reduced motion.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called Parallax (no animation library). - A frame (position relative, overflow-hidden) sized by className, holding an absolutely positioned layer that is oversized by `speed` percent (inset -speed/2% top and bottom) and stretches its children to fill it. - In a passive scroll listener, measure the frame with getBoundingClientRect and compute progress = (innerHeight - rect.top) / (innerHeight + rect.height), clamped to 0–1. - Write transform = translate3d(0, (0.5 - progress) * distance, 0) straight to the layer's style, where distance = frame height * speed / 100. Re-measure on resize (ResizeObserver) — never animate with state. - direction "horizontal" shifts on X instead, using the frame width. - Skip everything when prefers-reduced-motion is set.











