Scroll Text
Scroll-triggered text animations for React. Includes fade, blur, scale, parallax, and sticky reveal effects. Built with Framer Motion.
Last updated on
Fade Up
import { ScrollText } from "@/components/ui/scroll-text";
export function ScrollTextFadeUpDemo() {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<ScrollText effect="fadeUp" className="font-bold text-4xl">
Fade Up Animation
</ScrollText>
</div>
);
}Blur
import { ScrollText } from "@/components/ui/scroll-text";
export function ScrollTextBlurDemo() {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<ScrollText effect="blur" className="font-bold text-4xl">
Blur Animation
</ScrollText>
</div>
);
}Scale
import { ScrollText } from "@/components/ui/scroll-text";
export function ScrollTextScaleDemo() {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<ScrollText effect="scale" className="font-bold text-4xl">
Scale Animation
</ScrollText>
</div>
);
}Rotate
import { ScrollText } from "@/components/ui/scroll-text";
export function ScrollTextRotateDemo() {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<ScrollText effect="rotate" className="font-bold text-4xl">
Rotate Animation
</ScrollText>
</div>
);
}Slide Left
import { ScrollText } from "@/components/ui/scroll-text";
export function ScrollTextSlideLeftDemo() {
return (
<div className="flex h-[400px] w-full items-center justify-center">
<ScrollText effect="slideLeft" className="font-bold text-4xl">
Slide Left Animation
</ScrollText>
</div>
);
}Horizontal Scroll
Create marquee-like horizontal scrolling text that moves with the page scroll.
import { HorizontalScrollText } from "@/components/ui/scroll-text";
export function HorizontalScrollTextDemo() {
return (
<div className="w-full overflow-hidden py-20">
<HorizontalScrollText
className="font-black text-6xl uppercase"
speed={0.5}
>
Scroll Text • Animation • Motion • React •
</HorizontalScrollText>
<div className="h-10" />
<HorizontalScrollText
className="font-black text-6xl text-primary uppercase"
direction="right"
speed={0.5}
>
Creative • Innovative • Dynamic • Powerful •
</HorizontalScrollText>
</div>
);
}Scroll Reveal
Reveal content as it enters the viewport with customizable direction and delay.
import { ScrollReveal } from "@/components/ui/scroll-text";
export function ScrollRevealDemo() {
return (
<div className="flex w-full flex-col items-center justify-center gap-8 py-20">
<ScrollReveal direction="up" className="rounded-xl bg-primary/10 p-8">
<h3 className="mb-2 font-bold text-2xl">Reveal Up</h3>
<p className="text-muted-foreground">
This content reveals from the bottom.
</p>
</ScrollReveal>
<ScrollReveal
direction="left"
delay={0.2}
className="rounded-xl bg-primary/10 p-8"
>
<h3 className="mb-2 font-bold text-2xl">Reveal Left</h3>
<p className="text-muted-foreground">
This content reveals from the right.
</p>
</ScrollReveal>
<ScrollReveal
direction="right"
delay={0.4}
className="rounded-xl bg-primary/10 p-8"
>
<h3 className="mb-2 font-bold text-2xl">Reveal Right</h3>
<p className="text-muted-foreground">
This content reveals from the left.
</p>
</ScrollReveal>
</div>
);
}Scroll Progress
Highlight text character-by-character based on scroll progress.
import { ScrollProgressText } from "@/components/ui/scroll-text";
export function ScrollProgressTextDemo() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-20">
<ScrollProgressText
text="As you scroll down, this text will light up character by character, creating a beautiful reading experience that guides the user's eye through the content."
className="font-bold text-4xl leading-tight"
/>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/scroll-text"Manual
Install the following dependencies:
npm install motionCopy and paste the following code into your project.
import {
type MotionValue,
motion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
// ============================================================================
// TYPES
// ============================================================================
type ScrollEffect =
| "fadeIn"
| "fadeUp"
| "fadeDown"
| "parallax"
| "scale"
| "scaleUp"
| "scaleDown"
| "rotate"
| "blur"
| "slideLeft"
| "slideRight"
| "skew"
| "flip"
| "reveal";
interface ScrollTextProps {
children: React.ReactNode;
effect?: ScrollEffect;
className?: string;
offset?: [string, string];
speed?: number;
spring?: boolean;
springConfig?: { stiffness?: number; damping?: number; mass?: number };
threshold?: [number, number];
}
interface ParallaxTextProps {
children: React.ReactNode;
className?: string;
speed?: number;
direction?: "up" | "down" | "left" | "right";
spring?: boolean;
}
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
direction?: "up" | "down" | "left" | "right";
delay?: number;
duration?: number;
distance?: number;
once?: boolean;
}
interface ScrollFadeProps {
children: React.ReactNode;
className?: string;
fadeIn?: boolean;
fadeOut?: boolean;
threshold?: [number, number];
}
interface ScrollScaleProps {
children: React.ReactNode;
className?: string;
from?: number;
to?: number;
threshold?: [number, number];
}
interface HorizontalScrollTextProps {
children: React.ReactNode;
className?: string;
speed?: number;
direction?: "left" | "right";
repeat?: number;
}
interface ScrollProgressTextProps {
text: string;
className?: string;
charClassName?: string;
stagger?: number;
}
interface StickyScrollTextProps {
children: React.ReactNode;
className?: string;
height?: string;
effect?: "fade" | "scale" | "blur" | "color";
}
// ============================================================================
// SCROLL TEXT COMPONENT
// ============================================================================
const ScrollText = React.forwardRef<HTMLDivElement, ScrollTextProps>(
(
{
children,
effect = "fadeIn",
className,
offset = ["start end", "end start"],
speed = 1,
spring = false,
springConfig,
threshold = [0, 1],
},
forwardedRef,
) => {
const internalRef = React.useRef<HTMLDivElement>(null);
const ref =
(forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
const { scrollYProgress } = useScroll({
target: ref,
offset: offset as ["start end", "end start"],
});
const [start, end] = threshold;
const springOpts = {
stiffness: springConfig?.stiffness ?? 100,
damping: springConfig?.damping ?? 30,
mass: springConfig?.mass ?? 1,
};
// Create transforms
const rawOpacity = useTransform(scrollYProgress, [start, end], [0, 1]);
const rawY = useTransform(scrollYProgress, [start, end], [50 * speed, 0]);
const rawYDown = useTransform(
scrollYProgress,
[start, end],
[-50 * speed, 0],
);
const rawYParallax = useTransform(
scrollYProgress,
[0, 1],
[100 * speed, -100 * speed],
);
const rawScale = useTransform(scrollYProgress, [start, end], [0.5, 1]);
const rawScaleUp = useTransform(
scrollYProgress,
[0, 0.5, 1],
[0.8, 1, 1.2],
);
const rawScaleDown = useTransform(
scrollYProgress,
[0, 0.5, 1],
[1.2, 1, 0.8],
);
const rawRotate = useTransform(scrollYProgress, [0, 1], [0, 360 * speed]);
const rawBlurOpacity = useTransform(
scrollYProgress,
[start, end],
[0.5, 1],
);
const rawX = useTransform(scrollYProgress, [start, end], [200 * speed, 0]);
const rawXRight = useTransform(
scrollYProgress,
[start, end],
[-200 * speed, 0],
);
const rawSkew = useTransform(
scrollYProgress,
[start, end],
[20 * speed, 0],
);
const rawRotateX = useTransform(scrollYProgress, [start, end], [90, 0]);
// Apply spring if needed
const opacitySpring = useSpring(rawOpacity, springOpts);
const ySpring = useSpring(rawY, springOpts);
const yDownSpring = useSpring(rawYDown, springOpts);
const yParallaxSpring = useSpring(rawYParallax, springOpts);
const scaleSpring = useSpring(rawScale, springOpts);
const scaleUpSpring = useSpring(rawScaleUp, springOpts);
const scaleDownSpring = useSpring(rawScaleDown, springOpts);
const rotateSpring = useSpring(rawRotate, springOpts);
const blurOpacitySpring = useSpring(rawBlurOpacity, springOpts);
const rotateXSpring = useSpring(rawRotateX, springOpts);
const opacity = spring ? opacitySpring : rawOpacity;
const y = spring ? ySpring : rawY;
const yDown = spring ? yDownSpring : rawYDown;
const yParallax = spring ? yParallaxSpring : rawYParallax;
const scale = spring ? scaleSpring : rawScale;
const scaleUp = spring ? scaleUpSpring : rawScaleUp;
const scaleDown = spring ? scaleDownSpring : rawScaleDown;
const rotate = spring ? rotateSpring : rawRotate;
const blurOpacity = spring ? blurOpacitySpring : rawBlurOpacity;
const rotateX = spring ? rotateXSpring : rawRotateX;
const xSpring = useSpring(rawX, springOpts);
const xRightSpring = useSpring(rawXRight, springOpts);
const skewSpring = useSpring(rawSkew, springOpts);
const x = spring ? xSpring : rawX;
const xRight = spring ? xRightSpring : rawXRight;
const skew = spring ? skewSpring : rawSkew;
// Non-springable transforms
const blur = useTransform(scrollYProgress, [start, end], [10 * speed, 0]);
const clipPath = useTransform(scrollYProgress, [start, end], [100, 0]);
const effectStyles: Record<
ScrollEffect,
Record<string, MotionValue<number>>
> = {
fadeIn: { opacity },
fadeUp: { opacity, y },
fadeDown: { opacity, y: yDown },
parallax: { y: yParallax },
scale: { scale, opacity },
scaleUp: { scale: scaleUp },
scaleDown: { scale: scaleDown },
rotate: { rotate },
blur: { opacity: blurOpacity },
slideLeft: { x, opacity },
slideRight: { x: xRight, opacity },
skew: { skewX: skew, opacity },
flip: { rotateX, opacity },
reveal: {},
};
const filterBlur = useTransform(blur, (v) => `blur(${v}px)`);
const clipPathValue = useTransform(clipPath, (v) => `inset(0 ${v}% 0 0)`);
return (
<motion.div
ref={ref}
className={cn("will-change-transform", className)}
style={{
...effectStyles[effect],
...(effect === "blur" && { filter: filterBlur }),
...(effect === "reveal" && { clipPath: clipPathValue }),
...(effect === "flip" && { perspective: 1000 }),
}}
>
{children}
</motion.div>
);
},
);
ScrollText.displayName = "ScrollText";
// ============================================================================
// PARALLAX TEXT COMPONENT
// ============================================================================
const ParallaxText = React.forwardRef<HTMLDivElement, ParallaxTextProps>(
(
{ children, className, speed = 1, direction = "up", spring = true },
ref,
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"],
});
const distance = 100 * speed;
const springOpts = spring
? { stiffness: 100, damping: 30 }
: { stiffness: 1000, damping: 1000 };
const yUp = useSpring(
useTransform(scrollYProgress, [0, 1], [distance, -distance]),
springOpts,
);
const yDown = useSpring(
useTransform(scrollYProgress, [0, 1], [-distance, distance]),
springOpts,
);
const xLeft = useSpring(
useTransform(scrollYProgress, [0, 1], [distance, -distance]),
springOpts,
);
const xRight = useSpring(
useTransform(scrollYProgress, [0, 1], [-distance, distance]),
springOpts,
);
const isHorizontal = direction === "left" || direction === "right";
const transform = {
up: yUp,
down: yDown,
left: xLeft,
right: xRight,
}[direction];
return (
<div ref={containerRef} className={cn("overflow-hidden", className)}>
<motion.div
ref={ref}
style={{ [isHorizontal ? "x" : "y"]: transform }}
className="will-change-transform"
>
{children}
</motion.div>
</div>
);
},
);
ParallaxText.displayName = "ParallaxText";
// ============================================================================
// SCROLL REVEAL COMPONENT
// ============================================================================
const ScrollReveal = React.forwardRef<HTMLDivElement, ScrollRevealProps>(
(
{
children,
className,
direction = "up",
delay = 0,
duration = 0.6,
distance = 60,
once = true,
},
ref,
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = React.useState(false);
React.useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setIsInView(true);
if (once && containerRef.current) {
observer.unobserve(containerRef.current);
}
} else if (!once) {
setIsInView(false);
}
},
{ threshold: 0.1 },
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [once]);
const getInitialPosition = () => {
switch (direction) {
case "up":
return { y: distance, x: 0 };
case "down":
return { y: -distance, x: 0 };
case "left":
return { x: distance, y: 0 };
case "right":
return { x: -distance, y: 0 };
}
};
const initial = getInitialPosition();
return (
<div ref={containerRef} className={className}>
<motion.div
ref={ref}
initial={{ opacity: 0, ...initial }}
animate={
isInView ? { opacity: 1, x: 0, y: 0 } : { opacity: 0, ...initial }
}
transition={{ duration, delay, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
</div>
);
},
);
ScrollReveal.displayName = "ScrollReveal";
// ============================================================================
// SCROLL FADE COMPONENT
// ============================================================================
const ScrollFade = React.forwardRef<HTMLDivElement, ScrollFadeProps>(
(
{
children,
className,
fadeIn = true,
fadeOut = true,
threshold = [0.2, 0.8],
},
ref,
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"],
});
const opacity = useTransform(scrollYProgress, (value) => {
if (fadeIn && fadeOut) {
if (value < threshold[0]) return value / threshold[0];
if (value > threshold[1])
return 1 - (value - threshold[1]) / (1 - threshold[1]);
return 1;
}
if (fadeIn) return Math.min(value / threshold[0], 1);
if (fadeOut)
return value > threshold[1]
? 1 - (value - threshold[1]) / (1 - threshold[1])
: 1;
return 1;
});
return (
<motion.div ref={containerRef} className={className} style={{ opacity }}>
<div ref={ref}>{children}</div>
</motion.div>
);
},
);
ScrollFade.displayName = "ScrollFade";
// ============================================================================
// SCROLL SCALE COMPONENT
// ============================================================================
const ScrollScale = React.forwardRef<HTMLDivElement, ScrollScaleProps>(
({ children, className, from = 0.8, to = 1, threshold = [0, 0.5] }, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"],
});
const scale = useSpring(
useTransform(scrollYProgress, threshold, [from, to]),
{ stiffness: 100, damping: 30 },
);
const opacity = useTransform(scrollYProgress, threshold, [0, 1]);
return (
<motion.div
ref={containerRef}
className={cn("will-change-transform", className)}
style={{ scale, opacity }}
>
<div ref={ref}>{children}</div>
</motion.div>
);
},
);
ScrollScale.displayName = "ScrollScale";
// ============================================================================
// HORIZONTAL SCROLL TEXT COMPONENT
// ============================================================================
const HorizontalScrollText = React.forwardRef<
HTMLDivElement,
HorizontalScrollTextProps
>(({ children, className, speed = 1, direction = "left", repeat = 3 }, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"],
});
const x = useTransform(
scrollYProgress,
[0, 1],
direction === "left"
? ["0%", `-${50 * speed}%`]
: [`-${50 * speed}%`, "0%"],
);
return (
<div ref={containerRef} className={cn("overflow-hidden", className)}>
<motion.div
ref={ref}
className="flex whitespace-nowrap will-change-transform"
style={{ x }}
>
{Array.from({ length: repeat }).map((_, i) => (
<span key={i} className="flex-shrink-0 px-4">
{children}
</span>
))}
</motion.div>
</div>
);
});
HorizontalScrollText.displayName = "HorizontalScrollText";
// ============================================================================
// SCROLL PROGRESS TEXT COMPONENT
// ============================================================================
interface ScrollProgressCharProps {
char: string;
progress: MotionValue<number>;
range: [number, number];
className?: string;
}
const ScrollProgressChar: React.FC<ScrollProgressCharProps> = ({
char,
progress,
range,
className,
}) => {
const opacity = useTransform(progress, range, [0.2, 1]);
const color = useTransform(progress, range, [
"hsl(var(--muted-foreground))",
"hsl(var(--foreground))",
]);
return (
<motion.span
style={{ opacity, color }}
className={cn("transition-colors", className)}
>
{char === " " ? "\u00A0" : char}
</motion.span>
);
};
const ScrollProgressText = React.forwardRef<
HTMLDivElement,
ScrollProgressTextProps
>(({ text, className, charClassName }, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start 0.9", "start 0.25"],
});
const characters = text.split("");
const step = 1 / characters.length;
return (
<div ref={containerRef} className={className}>
<span ref={ref} className="flex flex-wrap">
{characters.map((char, index) => {
const start = index * step;
const end = start + step;
return (
<ScrollProgressChar
key={index}
char={char}
progress={scrollYProgress}
range={[start, end]}
className={charClassName}
/>
);
})}
</span>
</div>
);
});
ScrollProgressText.displayName = "ScrollProgressText";
// ============================================================================
// STICKY SCROLL TEXT COMPONENT
// ============================================================================
const StickyScrollText = React.forwardRef<
HTMLDivElement,
StickyScrollTextProps
>(({ children, className, height = "200vh", effect = "fade" }, ref) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end start"],
});
const opacityFade = useTransform(
scrollYProgress,
[0, 0.3, 0.7, 1],
[0, 1, 1, 0],
);
const scaleValue = useTransform(
scrollYProgress,
[0, 0.3, 0.7, 1],
[0.5, 1, 1, 0.5],
);
const blurValue = useTransform(
scrollYProgress,
[0, 0.3, 0.7, 1],
[10, 0, 0, 10],
);
const filterBlur = useTransform(blurValue, (v) => `blur(${v}px)`);
const color = useTransform(
scrollYProgress,
[0, 0.5, 1],
[
"hsl(var(--muted-foreground))",
"hsl(var(--primary))",
"hsl(var(--muted-foreground))",
],
);
const effectStyles = {
fade: { opacity: opacityFade },
scale: { scale: scaleValue, opacity: opacityFade },
blur: { filter: filterBlur, opacity: opacityFade },
color: { opacity: opacityFade, color },
};
return (
<div ref={containerRef} className="relative" style={{ height }}>
<motion.div
ref={ref}
className={cn(
"-translate-y-1/2 sticky top-1/2 will-change-transform",
className,
)}
style={effectStyles[effect]}
>
{children}
</motion.div>
</div>
);
});
StickyScrollText.displayName = "StickyScrollText";
// ============================================================================
// EXPORTS
// ============================================================================
export {
HorizontalScrollText,
ParallaxText,
ScrollFade,
ScrollProgressText,
ScrollReveal,
ScrollScale,
ScrollText,
StickyScrollText,
};
export type {
HorizontalScrollTextProps,
ParallaxTextProps,
ScrollEffect,
ScrollFadeProps,
ScrollProgressTextProps,
ScrollRevealProps,
ScrollScaleProps,
ScrollTextProps,
StickyScrollTextProps,
};Features
- Multiple Effects: Fade, Scale, Rotate, Blur, Slide, and more.
- Scroll Triggers: Animations are driven by scroll position.
- Spring Physics: Smooth, natural motion using spring animations.
- Specialized Components: Includes
ScrollReveal,HorizontalScrollText,ScrollProgressText, andStickyScrollText.
API Reference
ScrollText
The main component for scroll-triggered animations.
Prop
Type
ScrollReveal
A component for revealing content as it enters the viewport.
Prop
Type
HorizontalScrollText
A component for horizontally scrolling text.
Prop
Type
ScrollProgressText
A component for highlighting text based on scroll progress.
Prop
Type
StickyScrollText
A component for sticky scroll effects.
Prop
Type
How is this guide?
Rotate Text
Word rotation animation for React. Smooth vertical flip transitions with spring physics. Great for hero headlines and dynamic taglines.
Text Morphing
Smooth text morphing animation for React. Words transition with blur and scale effects. Perfect for dynamic hero sections and attention-grabbing headlines.