Components

Scroll Text

A collection of scroll-triggered text animations including reveal, fade, scale, horizontal scroll, and sticky effects.

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