A feature demo for any tool that reads text. A bright vertical line with a soft trail sweeps across a paragraph from left to right. As it passes the end of each marked phrase, that phrase picks up an amber highlight with a wavy underline, a counter ticks up and a matching suggestion chip fills in below. After a pause the beam sweeps back, clears the marks and starts again; the button runs one scan and leaves the result on screen. Here it is dressed as a plain language checker for an official letter. The paragraph wraps freely, so it works at any width.
// Adapted from Animata (https://animata.design/docs/feature-cards/content-scan)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, useState } from "react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion } from "framer-motion";
import { ArrowRight, ScanLine } from "lucide-react";
// The sample text. A plain string is ordinary text; an object is a phrase the scan should flag, with a simpler swap.
const content: Array<string | { phrase: string; plain: string }> = [
{ phrase: "Pursuant to", plain: "According to" },
" our records, your account is due for renewal. ",
{ phrase: "In the event that", plain: "If" },
" you wish to continue, please ",
{ phrase: "utilise", plain: "use" },
" the enclosed form. ",
{ phrase: "At this point in time", plain: "Right now" },
" we are unable to ",
{ phrase: "commence", plain: "start" },
" the transfer without it. ",
{ phrase: "In order to", plain: "To" },
" avoid delays, we will ",
{ phrase: "endeavour to", plain: "try to" },
" confirm receipt within five working days.",
];
const SCAN_SECONDS = 2.6;
const RETURN_SECONDS = 0.8;
const HOLD_MS = 2600;
const flagged = content.filter((part): part is { phrase: string; plain: string } => typeof part !== "string");
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
type Phase = "idle" | "scanning" | "done" | "returning";
export default function TextScannerWithSweepingBeam() {
const reduce = useReducedMotion();
const paperRef = useRef<HTMLDivElement>(null);
const spanRefs = useRef<Array<HTMLSpanElement | null>>([]);
const x = useMotionValue(0);
const runId = useRef(0);
const [phase, setPhase] = useState<Phase>("idle");
// One true or false per flagged phrase: has the beam passed it yet?
const [lit, setLit] = useState<boolean[]>(() => flagged.map(() => false));
const found = lit.filter(Boolean).length;
const mark = useCallback((next: boolean[]) => setLit((prev) => (prev.join() === next.join() ? prev : next)), []);
const scan = useCallback(
async (loop: boolean) => {
const paper = paperRef.current;
if (!paper) return;
const id = ++runId.current;
const alive = () => runId.current === id;
// Where each flagged phrase ends, measured from the left edge of the paper.
const ends = spanRefs.current.map((el) => (el ? el.offsetLeft + el.offsetWidth : 0));
const width = paper.offsetWidth;
const passed = (pos: number) => ends.map((end) => end <= pos);
do {
setPhase("scanning");
await animate(x, width, { duration: SCAN_SECONDS, ease: "linear", onUpdate: (pos) => alive() && mark(passed(pos)) });
if (!alive()) return;
mark(flagged.map(() => true));
setPhase("done");
if (!loop) return;
await wait(HOLD_MS);
if (!alive()) return;
setPhase("returning");
await animate(x, 0, { duration: RETURN_SECONDS, ease: "easeInOut", onUpdate: (pos) => alive() && mark(passed(pos)) });
if (!alive()) return;
mark(flagged.map(() => false));
await wait(500);
} while (alive());
},
[x, mark],
);
useEffect(() => {
if (reduce) {
mark(flagged.map(() => true));
setPhase("done");
return;
}
const timer = setTimeout(() => scan(true), 400);
return () => {
clearTimeout(timer);
runId.current += 1; // cancels a scan in progress
x.stop();
};
}, [reduce, scan, x, mark]);
const rescan = () => {
if (reduce) return;
x.stop();
x.set(0);
mark(flagged.map(() => false));
scan(false);
};
const busy = phase === "scanning" || phase === "returning";
let phraseIndex = -1;
return (
<section className="bg-sky-50 px-4 py-14 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold uppercase tracking-widest text-sky-800">Plain words checker</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Paste a letter. See what readers trip over.</h2>
<p className="mx-auto mt-3 max-w-xl text-base text-slate-600">
The scan reads your text from left to right and marks stiff, official phrases, with a shorter way to say each one.
</p>
</div>
<div className="mx-auto mt-8 max-w-2xl rounded-3xl bg-white p-4 shadow-xl shadow-sky-900/5 ring-1 ring-sky-100 sm:p-6">
<div ref={paperRef} className="relative overflow-hidden rounded-2xl border border-slate-200 bg-slate-50">
<p className="px-4 py-5 text-left text-base leading-8 text-slate-700 sm:px-6 sm:text-lg sm:leading-9">
{content.map((part, i) => {
if (typeof part === "string") return <span key={i}>{part}</span>;
phraseIndex += 1;
const index = phraseIndex;
const on = lit[index];
return (
<span
key={i}
ref={(node) => {
spanRefs.current[index] = node;
}}
className={`rounded px-0.5 transition-colors duration-300 motion-reduce:transition-none ${
on ? "bg-amber-200 text-amber-950 underline decoration-amber-600 decoration-wavy underline-offset-4" : ""
}`}
>
{part.phrase}
</span>
);
})}
</p>
{/* The beam: a bright line with a soft trail behind it. */}
{!reduce && (
<motion.div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 w-0" style={{ x }}>
<div className={`absolute inset-y-0 right-0 flex w-28 transition-opacity duration-300 ${busy ? "opacity-100" : "opacity-0"}`}>
<div className="h-full flex-1 bg-gradient-to-r from-transparent to-sky-400/30" />
<div className="h-full w-0.5 bg-sky-500 shadow-[0_0_12px_2px_rgba(14,165,233,0.7)]" />
</div>
</motion.div>
)}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p aria-live="polite" aria-atomic="true" className="flex items-baseline gap-2 text-sm text-slate-700">
<span className="relative inline-flex h-9 w-7 justify-center overflow-hidden text-3xl font-semibold tabular-nums text-slate-900">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={found}
initial={reduce ? false : { y: "70%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: "-70%", opacity: 0 }}
transition={{ duration: 0.18 }}
className="absolute inset-0 flex items-center justify-center"
>
{found}
</motion.span>
</AnimatePresence>
</span>
{phase === "scanning" ? "phrases flagged so far" : found === 1 ? "phrase could be simpler" : "phrases could be simpler"}
</p>
<button
type="button"
onClick={rescan}
disabled={phase === "scanning"}
className="inline-flex h-11 items-center gap-2 rounded-full bg-sky-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-sky-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 disabled:bg-slate-300 disabled:text-slate-700"
>
<ScanLine aria-hidden="true" className="h-4 w-4" />
{phase === "scanning" ? "Scanning" : "Scan again"}
</button>
</div>
<div className="mt-4 border-t border-slate-200 pt-4 text-left">
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500">Suggested swaps</h3>
<ul className="mt-3 flex min-h-[7.25rem] flex-wrap content-start gap-2">
{flagged.map((item, i) => {
const shown = lit[i];
return (
<li
key={item.phrase}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-all duration-300 motion-reduce:transition-none ${
shown ? "translate-y-0 border-slate-200 bg-white opacity-100" : "translate-y-1 border-transparent bg-slate-100 opacity-40"
}`}
>
<span className={shown ? "text-slate-600 line-through" : "text-slate-600"}>{item.phrase.toLowerCase()}</span>
<ArrowRight aria-hidden="true" className="h-3 w-3 text-slate-500" />
<span className="font-semibold text-slate-900">{shown ? item.plain.toLowerCase() : "?"}</span>
</li>
);
})}
</ul>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/text-scanner-with-sweeping-beam-that-flags-phrases.json
At the start of each scan the component measures where every flagged phrase ends, as a distance from the left edge of the text box. While the beam moves, its position is compared with those distances and every phrase that ends before it counts as found.
No. The beam is one tall line that crosses all the lines together, which reads as a scanner pass. A phrase lights up when the beam passes its right edge, whichever line it is on.
Yes. Build the `content` array from your results by splitting the text at each flagged range, then call `scan(false)`. The animation only needs to know which pieces are flagged.
Rendering each piece as a React element avoids setting raw HTML, which is a common way to let unsafe markup into a page when the text comes from users.
Wrap the first effect's `scan(true)` call in an `IntersectionObserver` callback on the section, and raise `runId.current` by one when it leaves the screen to cancel the loop.
Adapted from Content Scan in Animata, used under its open-source licence. Licences and credits
Related: Marker Highlight Text That Sweeps Across Phrases, Text Scramble Decode Headline, Multi-Step Loading Sequence with Drawn Checkmarks.
More feature sections 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.