ComponentsText Animations

Scroll Text

Scroll-triggered text animations for React. Includes fade, blur, scale, parallax, and sticky reveal effects. Built with Framer Motion.

Last updated on

Edit on GitHub

Fade Up

Open in
Fade Up Animation
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

Open in
Blur Animation
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

Open in
Scale Animation
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

Open in
Rotate Animation
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

Open in
Slide Left Animation
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 motion

Copy 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, and StickyScrollText.

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?

On this page