Scroll Reveal
Content that moves from an offset into place while it scrolls into view.
From the Nitip template — see it in context1. Tied to scroll
Progress runs from the bottom of the viewport to the top of the block.
2. Reversible
Scroll back up and it plays backwards — no snapping, no easing.
import { ScrollReveal } from "@/components/ui/scroll-reveal";
const cards = [
{ title: "Tied to scroll", body: "Progress runs from the bottom of the viewport to the top of the block." },
{ title: "Reversible", body: "Scroll back up and it plays backwards — no snapping, no easing." },
];
export default function ScrollRevealDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-4">
{cards.map((card, i) => (
<ScrollReveal key={card.title} from={{ y: 40, scale: 0.96, opacity: 0 }}>
<div className="rounded-lg border bg-card p-5">
<p className="text-sm font-medium">
{i + 1}. {card.title}
</p>
<p className="mt-1 text-sm text-muted-foreground">{card.body}</p>
</div>
</ScrollReveal>
))}
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/scroll-reveal.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/scroll-reveal.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Scroll Reveal — content that moves from an offset into place while it scrolls into view * (from Framer's "Scroll Transform · In View", as used across the Nitip pages). * Progress is 0 when the frame's top reaches the bottom of the viewport and 1 when its bottom does, tied directly to * scroll (no easing, no jump), so scrolling back up plays it backwards. * * The transform is written straight to the element in a passive scroll listener, as Framer does: it never lags a frame * behind, and the frame itself never moves, so measuring stays correct when images load late. * Static with reduced motion. */ export type ScrollRevealOffset = { x?: number; y?: number; scale?: number; opacity?: number }; export type ScrollRevealProps = { children: React.ReactNode; /** Where the content starts: pixels for x/y, a factor for scale, 0–1 for opacity. */ from?: ScrollRevealOffset; className?: string; }; const transform = ({ x = 0, y = 0, scale = 1 }: ScrollRevealOffset) => `translate3d(${x}px, ${y}px, 0) scale(${scale})`; export function ScrollReveal({ children, from = { y: 60, opacity: 0 }, className }: ScrollRevealProps) { const frame = React.useRef<HTMLDivElement>(null); const content = React.useRef<HTMLDivElement>(null); const key = JSON.stringify(from); React.useEffect(() => { const frameEl = frame.current; const contentEl = content.current; if (!frameEl || !contentEl) return; const start = JSON.parse(key) as ScrollRevealOffset; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { contentEl.style.transform = ""; contentEl.style.opacity = ""; return; } const update = () => { const rect = frameEl.getBoundingClientRect(); const progress = rect.height ? (window.innerHeight - rect.top) / rect.height : 1; const left = 1 - Math.min(1, Math.max(0, progress)); contentEl.style.transform = transform({ x: (start.x ?? 0) * left, y: (start.y ?? 0) * left, scale: 1 + ((start.scale ?? 1) - 1) * left, }); contentEl.style.opacity = `${1 + ((start.opacity ?? 1) - 1) * left}`; }; update(); const observer = new ResizeObserver(update); observer.observe(document.body); window.addEventListener("resize", update); window.addEventListener("scroll", update, { passive: true }); return () => { observer.disconnect(); window.removeEventListener("resize", update); window.removeEventListener("scroll", update); }; }, [key]); return ( <div ref={frame} className={cn("relative", className)}> <div ref={content} style={{ transform: transform(from), opacity: from.opacity ?? 1 }} className="will-change-transform" > {children} </div> </div> ); }
Usage
import { ScrollReveal } from "@/components/ui/scroll-reveal";
<ScrollReveal from={{ y: 60, opacity: 0 }}>
<h2>Everything we make, in one place</h2>
</ScrollReveal>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | What moves into place. |
| from | { x?, y?, scale?, opacity? } | { y: 60, opacity: 0 } | Where it starts: pixels for x/y, a factor for scale, 0–1 for opacity. |
| className | string | — | Classes for the frame, which never moves. |
- Tied directly to scroll, not to a timer: progress is 0 when the block's top reaches the bottom of the viewport and 1 when its bottom does, so scrolling back up plays it backwards.
- 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 ScrollReveal (no animation library).
- A frame that never moves, holding the content. The content starts at a given offset ({x, y, scale, opacity})
and reaches its resting state as the block scrolls into view.
- In a passive scroll listener: progress = (innerHeight - rect.top) / rect.height, clamped 0–1, left = 1 - progress;
write transform = translate3d(x*left, y*left, 0) scale(1 + (scale-1)*left) and opacity = 1 + (opacity-1)*left
straight to the element's style. Linear, no easing, reversible.
- Re-measure on resize with a ResizeObserver on document.body (images load late).
- Render the starting transform inline so there is no flash before the first scroll event.
- Skip everything when prefers-reduced-motion is set.










