A large statement whose words darken one by one as it scrolls into view, beside a round portrait. React and Tailwind CSS, no dependencies.
ScrollWordFillStatement.tsx
"use client";
// Statement whose words darken one by one as you scroll it into view,
// beside a round portrait. Pure Tailwind + React, no dependencies. Works with Tailwind v3 and v4.
import { useEffect, useRef, useState } from "react";
type Props = {
statement?: string;
image?: string;
imageAlt?: string;
link?: { label: string; href: string };
};
export default function WordFillStatement({
statement = "We believe good food starts with good soil, fair prices and people who know the land by name. Everything we grow is a promise to the next harvest.",
image = "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=300&h=300&q=80&auto=format&fit=crop&crop=faces",
imageAlt = "Portrait of the farmer",
link = { label: "Read our story", href: "#" },
}: Props) {
const ref = useRef<HTMLParagraphElement>(null);
const [progress, setProgress] = useState(1);
const words = statement.split(" ");
useEffect(() => {
const el = ref.current;
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const update = () => {
const rect = el.getBoundingClientRect();
const vh = window.innerHeight;
// 0 when the text enters from the bottom, 1 once it reaches the upper third
setProgress(Math.min(1, Math.max(0, (vh - rect.top) / (vh * 0.66 + rect.height * 0.5))));
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, []);
const lit = Math.round(progress * words.length);
return (
<section className="bg-[#f6f0de] px-4 py-24 font-['Geist',ui-sans-serif,system-ui,sans-serif] text-[#0f1712] antialiased md:px-8 md:py-36">
<div className="mx-auto grid max-w-7xl gap-10 md:grid-cols-12">
<div className="md:col-span-3">
<img src={image} alt={imageAlt} className="h-20 w-20 rounded-full object-cover md:h-36 md:w-36" />
</div>
<div className="md:col-span-9">
<p ref={ref} className="font-['Newsreader',Georgia,serif] text-[clamp(1.875rem,3.3vw,3.25rem)] leading-[1.15]" aria-label={statement}>
{words.map((word, i) => (
<span key={i} aria-hidden className={`transition-colors duration-300 ${i < lit ? "text-[#0f1712]" : "text-[#0f1712]/20"}`}>
{word}{" "}
</span>
))}
</p>
{link && (
<a href={link.href} className="mt-10 inline-flex items-center gap-2 border-b border-[#0f1712]/30 pb-1 text-sm font-medium transition-colors hover:border-[#0f1712]">
{link.label} →
</a>
)}
</div>
</div>
</section>
);
}
How to use it
- Make sure your Next.js or React project has Tailwind CSS set up (v3 or v4 both work).
- Create
components/ScrollWordFillStatement.tsxand paste the code. - Import it into a page and replace the sample text and images with your own.
