A two column section where a fixed sentence on the left is completed by a scrolling list of large words on the right. The list lives in its own short scroll window. As it moves, each word springs in from the left or right with a small rotation, and whichever word sits at the centre turns from pale grey to black with a green marker beside it. Scrolling snaps one word at a time, the edges fade out, and a line underneath names the current word. The preview steps through the list by itself until someone scrolls. On phones the two columns stack and the words centre.
// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/scroll-text.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
const words = ["bakers", "florists", "potters", "tailors", "roasters", "printers", "luthiers", "brewers", "framers", "weavers"];
const ROW = 84; // height of one word row in pixels
const VISIBLE_ROWS = 4; // how many rows tall the scroll window is
const DEMO_STEP_MS = 1200;
export default function ScrollHighlightedWordListWithSpringEntrances() {
const rootRef = useRef<HTMLElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const inView = useInView(rootRef);
const reduce = useReducedMotion();
const [active, setActive] = useState(0);
const [demo, setDemo] = useState(true);
const pad = ((VISIBLE_ROWS - 1) * ROW) / 2;
// The word whose row is nearest the middle of the window is the active one.
const onScroll = () => {
const box = boxRef.current;
if (box) setActive(Math.max(0, Math.min(words.length - 1, Math.round(box.scrollTop / ROW))));
};
// Self-playing preview: step down the list, then return to the top. Any real input stops it.
useEffect(() => {
if (!demo || !inView || reduce) return;
const timer = setTimeout(() => {
const next = (active + 1) % words.length;
boxRef.current?.scrollTo({ top: next * ROW, behavior: "smooth" });
}, DEMO_STEP_MS);
return () => clearTimeout(timer);
}, [demo, inView, reduce, active]);
const stopDemo = () => setDemo(false);
return (
<section ref={rootRef} className="w-full bg-[#f3f0e8] px-4 py-16">
<div className="mx-auto grid max-w-5xl items-center gap-8 md:grid-cols-2 md:gap-4">
<div className="text-center md:text-left">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-emerald-800">Small shop websites</p>
<h2 className="mt-4 text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
A proper website for
<span className="sr-only"> {words.join(", ")}</span>
</h2>
<p className="mx-auto mt-4 max-w-sm text-base leading-relaxed text-stone-700 md:mx-0">
Opening hours, a price list and a way to order, built in an afternoon and yours to edit. Scroll the list to find your trade.
</p>
<a href="#" className="mt-7 inline-flex h-12 items-center justify-center rounded-full bg-emerald-800 px-7 text-sm font-semibold text-white transition hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-800 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f3f0e8]">
Start with a template
</a>
</div>
<div>
<div className="relative">
<div
ref={boxRef}
onScroll={onScroll}
onWheel={stopDemo}
onTouchStart={stopDemo}
onPointerDown={stopDemo}
onKeyDown={stopDemo}
tabIndex={0}
role="group"
aria-label="List of trades. Scroll or use the arrow keys."
className="relative snap-y snap-mandatory overflow-y-auto overflow-x-hidden rounded-2xl [-ms-overflow-style:none] [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-800 [&::-webkit-scrollbar]:hidden"
style={{ height: VISIBLE_ROWS * ROW }}
>
<div style={{ height: pad }} />
<ul aria-hidden="true">
{words.map((word, i) => (
<motion.li
key={word}
className="flex snap-center items-center justify-center gap-4 md:justify-start md:pl-6"
style={{ height: ROW }}
initial={reduce ? false : { opacity: 0, x: i % 2 === 0 ? -100 : 100, rotate: i % 2 === 0 ? -10 : 10 }}
whileInView={{ opacity: 1, x: 0, rotate: 0 }}
viewport={{ root: boxRef, once: false, margin: "-8% 0px -8% 0px" }}
transition={{ type: "spring", stiffness: 100, damping: 15 }}
>
<span className={`hidden h-3 w-3 shrink-0 rounded-full transition-all duration-300 md:block ${active === i ? "scale-100 bg-emerald-600" : "scale-50 bg-stone-300"}`} />
<span className={`whitespace-nowrap text-5xl font-bold tracking-tight transition-colors duration-300 sm:text-6xl ${active === i ? "text-stone-900" : "text-stone-400/70"}`}>{word}</span>
</motion.li>
))}
</ul>
<div style={{ height: pad }} />
</div>
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 rounded-t-2xl bg-gradient-to-b from-[#f3f0e8] to-transparent" aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 rounded-b-2xl bg-gradient-to-t from-[#f3f0e8] to-transparent" aria-hidden="true" />
</div>
<p className="mt-3 text-center text-sm text-stone-600 md:pl-12 md:text-left" aria-live="polite">
{active + 1} of {words.length}: <span className="font-semibold text-stone-900">{words[active]}</span>
</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/scroll-highlighted-word-list-with-spring-entrances.json
Every row has the same height, so the scroll position divided by the row height, rounded, is the index of the centred word. Spacers above and below the list let the first and last words reach the centre.
Observers report when rows cross a line, which can skip a row during a fast fling. Reading the scroll position gives one definite answer on every scroll event.
Each row uses `whileInView` with the scroll window as its `root`. When a row enters the window it springs from an offset, rotated start to its resting place, and it replays each time it comes back in.
At either end of the list the browser hands scrolling back to the page, which is the default for nested scroll areas. Keep the list short enough that reaching the end is easy.
Start the `demo` state as `false`. The loop already stops for good on the first wheel, touch, press or key.
Adapted from Scroll Text in Kokonut UI, used under its open-source licence. Licences and credits
Related: Rotating Word Headline with Spring Flip, Sticky Scroll Storytelling with Pinned Visual, Word by Word Blur In Paragraph Reveal.
More scroll effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.