freeNew79 lines · framer-motion
Text Highlight Sweep
The part that matters, marked by hand. A full-width statement where one phrase gets a marker-style highlight that sweeps in behind it the moment the section enters view, like it was underlined on the spot.
- Use it for
- Pulling a single phrase out of a longer sentence on a landing page — a value prop, a number, a promise — without a separate callout box.
- Themes
- Light + dark
- License
- Commercial use
Live
Marked by hand
Most of this sentence is just here to set up the one part that actually matters, which is the whole point.
The highlight resets and sweeps in again every time the section re-enters view.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width kinetic text section: a sentence where one phrase gets a
* marker-style highlight that sweeps in behind it, like it was underlined
* by hand the moment it scrolled into view.
*/
export interface TextHighlightSweepProps {
eyebrow?: string;
/** Text before the highlighted phrase. */
lead?: string;
/** The phrase that gets the marker sweep. */
highlight?: string;
/** Text after the highlighted phrase. */
tail?: string;
subcopy?: string;
accent?: "signal" | "lime" | "info" | "ok";
sweepDuration?: number;
className?: string;
}
const SWEEP_BG: Record<string, string> = {
signal: "bg-signal/55",
lime: "bg-lime/60",
info: "bg-info/45",
ok: "bg-ok/45",
};
export function TextHighlightSweep({
eyebrow = "Marked by hand",
lead = "Most of this sentence is just here to set up",
highlight = "the one part that actually matters",
tail = ", which is the whole point.",
subcopy = "The highlight resets and sweeps in again every time the section re-enters view.",
accent = "signal",
sweepDuration = 0.7,
className,
}: TextHighlightSweepProps) {
const reduce = useReducedMotionSafe();
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-24 sm:px-6 lg:px-10", className)}>
<div className="relative mx-auto max-w-3xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-5 font-display text-[clamp(1.9rem,5vw,3.5rem)] font-bold leading-[1.25] tracking-[-0.02em]">
{lead}{" "}
<span className="relative isolate inline-block whitespace-normal">
<motion.span
aria-hidden
className={cn("absolute inset-x-[-0.08em] inset-y-[0.08em] -z-10 origin-left rounded-[0.2em]", SWEEP_BG[accent])}
initial={reduce ? { scaleX: 1 } : { scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: false, amount: 0.6 }}
transition={{ duration: sweepDuration, ease: [0.65, 0, 0.35, 1] }}
/>
<span className="relative">{highlight}</span>
</span>
{tail}
</h2>
<p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-muted-foreground">{subcopy}</p>
</div>
</section>
);
}
export default TextHighlightSweep;
How it behaves
- →The highlight is a separate layer behind the text that scales in from the left edge (scaleX 0 to 1) using whileInView.
- →Replays every time the section re-enters the viewport (viewport.once: false), so scrolling past it twice sweeps it twice.
- →sweepDuration controls how fast the mark fills in; accent picks which token colours the mark.
- →The sentence wraps normally around the highlighted phrase at any width.
- →prefers-reduced-motion renders the highlight already fully swept in, no animation.
Accessibility
- ✓The highlight layer sits behind the text (`-z-10`) and never affects text contrast below WCAG AA for the chosen accent.
- ✓Highlight layer is aria-hidden; the sentence itself carries all the real text content in reading order.
- ✓Honours prefers-reduced-motion by skipping straight to the swept-in state.
- ✓Honours prefers-reduced-motion.