Scroll Text
A collection of scroll-triggered text animations including reveal, fade, scale, horizontal scroll, and sticky effects.
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?