Docs
Scroll Text Flow
Scroll Text Flow
ScrollTextFlow is a React component that creates a dynamic scrolling effect with text and slide groups. As users scroll, the component reveals and animates slides while displaying the specified heading and subheading.
Scroll To animate
UI/UX
Success Designed Differently
The First The Last
Insights
Development
Miami
TFTL©27—SuccessDesignedDifferently. WeSpreadtheSexyness.
Sexyness
Awwwards
Development
Team
Insights
Success Designed Differently
Digital Experience
Marketing
Insights
Brand indentity
Creative Strategy
Sexyness
Digital Experience
Dubai
Strong Message
Design
Visual Language
Development
Install the following dependencies:
pnpm add gsap @gsap/react
Make a file for cn function and match the import afterwards
import clsx, { ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes))
Make a file and copy paste this code in a file with name scroll-text-flow.tsx
"use client"
import React, { useEffect, useRef, useState } from "react"
import { gsap } from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { cn } from "@/lib/utils"
import { useGSAP } from "@gsap/react"
gsap.registerPlugin(ScrollTrigger)
interface SlideItem {
name: string
style: string
}
interface ScrollTextFlowProps {
heading: string
subheading: string
slideGroups: SlideItem[][] // 2D array: each inner array is a row of slides
topGroupCount?: number // number of groups rendered above the text container, default is 2
wrapperClassName?: string // extra classes for the main container
textWrapperClassName?: string
groupClassName?: string // extra classes for each slide group row
scrollTriggerStart?: string // scrollTrigger start value, default: "top 20%"
scrollTriggerEnd?: string // scrollTrigger end value, default: "+=1500"
useMarkers?: boolean // whether to show markers, default: false
scrollerRef?: React.RefObject<HTMLDivElement> // Allow custom scroller
}
const ScrollTextFlow: React.FC<ScrollTextFlowProps> = ({
heading,
subheading,
slideGroups,
topGroupCount = 2,
wrapperClassName = "",
groupClassName = "",
textWrapperClassName = "",
scrollTriggerStart = "top 20%",
scrollTriggerEnd = "+=1500",
useMarkers = false,
scrollerRef,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const wordsContainerRef = useRef<HTMLDivElement | null>(null)
// Array to store refs for each slide group row
const groupRefs = useRef<(HTMLDivElement | null)[]>([])
const instanceIdRef = useRef<string>(
`rotating-text-${Math.random().toString(36).substring(2, 11)}`
)
const [forceUpdate, setForceUpdate] = useState(false)
useEffect(() => {
console.log("from useEffext")
if (scrollerRef?.current) {
setForceUpdate(!forceUpdate)
}
}, [scrollerRef?.current])
// Helper to add each group ref dynamically
const addToGroupRefs = (el: HTMLDivElement | null) => {
if (el && !groupRefs.current.includes(el)) {
groupRefs.current.push(el)
}
}
useGSAP(() => {
if (!containerRef.current || !wordsContainerRef.current) return
const existingTrigger = ScrollTrigger.getById(instanceIdRef.current)
const existingTrigger2 = ScrollTrigger.getById(instanceIdRef.current + "2")
if (existingTrigger && existingTrigger2) {
existingTrigger.kill()
existingTrigger2.kill()
}
// Fade-in animation for the text words
const words = wordsContainerRef.current.querySelectorAll(".word")
gsap.set(words, { opacity: 0 })
gsap.to(words, {
opacity: 1,
ease: "power1.inOut",
stagger: 0.3,
scrollTrigger: {
trigger: containerRef.current,
start: scrollTriggerStart,
end: scrollTriggerEnd,
scrub: true,
markers: useMarkers,
pin: true,
pinSpacing: true,
scroller: scrollerRef?.current ?? window,
id: instanceIdRef.current,
},
})
// Apply animation for each slide group row
groupRefs.current.forEach((group, index) => {
const isEven = index % 2 === 0
const fromX = isEven ? 50 : 150
const toX = isEven ? -200 - index * 50 : 0 + index * 30
const scrubSpeed = isEven ? 0.5 : 0.8
gsap.fromTo(
group,
{ x: fromX },
{
x: toX,
ease: "power2.out",
scrollTrigger: {
trigger: containerRef.current,
start: scrollTriggerStart,
end: scrollTriggerEnd,
scrub: scrubSpeed,
markers: useMarkers,
scroller: scrollerRef?.current ?? window,
id: instanceIdRef.current + "2",
},
}
)
})
}, [
slideGroups,
scrollTriggerStart,
scrollTriggerEnd,
useMarkers,
scrollerRef,
forceUpdate,
])
const words1 = heading.split(" ").map((word, index) => (
<span
key={index}
className={cn("word", "mr-2", "inline-block dark:text-white")}
>
{word}
</span>
))
const words2 = subheading.split(" ").map((word, index) => (
<span
key={index}
className={cn("word", "mr-2", "inline-block dark:text-white")}
>
{word}
</span>
))
return (
<div
ref={containerRef}
className={cn("flex justify-center h-screen py-5", wrapperClassName)}
>
<div className={cn(" space-y-3 md:space-y-5 mx-auto text-center w-full")}>
{/* Render slide groups above the text container */}
{slideGroups.map((group, idx) =>
idx < topGroupCount ? (
<div
key={idx}
ref={addToGroupRefs}
className={cn(
"flex justify-center items-center gap-5 md:gap-7 lg:gap-10 flex-nowrap",
groupClassName
)}
>
{group.map((item, i) => (
<div
key={i}
className={cn(
"text-[#232323] w-fit h-fit px-1 font-normal",
item.style
)}
style={{ whiteSpace: "nowrap" }}
>
{item.name}
</div>
))}
</div>
) : null
)}
{/* Words container for fade-in text */}
<div
ref={wordsContainerRef}
className={cn(
"flex justify-center items-center my-5 mb-8 max-w-[60%] mx-auto",
textWrapperClassName
)}
>
<p className={cn("text-4xl md:text-5xl font-bold mb-4")}>
{words1} <span className="text-lg md:text-xl">{words2}</span>
</p>
</div>
{/* Render slide groups below the text container */}
{slideGroups.map((group, idx) =>
idx >= topGroupCount ? (
<div
key={idx}
ref={addToGroupRefs}
className={cn(
"flex justify-center items-center gap-5 md:gap-7 lg:gap-10 flex-nowrap",
groupClassName
)}
>
{group.map((item, i) => (
<div
key={i}
className={cn(
"text-[#232323] w-fit h-fit px-1 font-normal",
item.style
)}
style={{ whiteSpace: "nowrap" }}
>
{item.name}
</div>
))}
</div>
) : null
)}
</div>
</div>
)
}
export { ScrollTextFlow }
Props
Name | Type | Description |
---|---|---|
heading * | string | The main heading of the component. |
subheading * | string | The subheading that appears below the heading. |
slideGroups * | SlideItem[][] | A 2D array of slide items, where each inner array represents a row of slides. |
topGroupCount | number | The number of slide groups rendered above the text container. Default is 2 . |
wrapperClassName | string | Additional CSS classes for the main container. |
textWrapperClassName | string | Extra CSS classes for the text container (heading and subheading). |
groupClassName | string | Extra CSS classes for each row of slide groups. |
scrollTriggerStart | string | The scroll position where the animation should start. Default is "top 20%" . |
scrollTriggerEnd | string | The scroll position where the animation should end. Default is "+=1500" . |
useMarkers | boolean | Whether to display ScrollTrigger markers for debugging. Default is false . |
scrollerRef | RefObject<HTMLElement> | A reference for the custom scroll container to synchronize scrolling behavior. |
Note: Props marked with
*
are required.
Credit: This component is inspired by The First The Last.