A newsletter masthead with a large serif sentence that contains three inline links, a subscribe link and a small section menu. Each link rests with a thick coloured underline. On hover or keyboard focus the underline springs upward until it fills the link like a label, and the text turns white. Leaving reverses it. Until a visitor interacts, the preview fills each headline link in turn so the effect is visible without a mouse. On phones the sentence wraps and the links keep working by tap and focus.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/underline-to-background)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
const UNDERLINE = 0.08; // underline thickness as a share of the line height (0.08 = 8%)
const DEMO_MS = 1500; // how long each link stays filled while the preview plays on its own
// Ink colours for the links. Text turns white when a link is filled, so keep these dark.
const TONES = { blue: "#1d4ed8", rose: "#be123c", green: "#047857", ink: "#0f172a" };
const sections = [
{ label: "Long reads", tone: TONES.blue },
{ label: "Money", tone: TONES.green },
{ label: "Culture", tone: TONES.rose },
{ label: "Letters", tone: TONES.ink },
{ label: "Archive", tone: TONES.blue },
];
function FillLink({ children, tone, active = false }: { children: ReactNode; tone: string; active?: boolean }) {
const reduce = useReducedMotion();
const [hovered, setHovered] = useState(false);
const on = hovered || active;
return (
<a
href="#"
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
className="relative inline-block rounded-[0.14em] px-[0.14em] transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"
style={{ color: on ? "#ffffff" : tone }}
>
<motion.span
aria-hidden="true"
className="absolute inset-0 origin-bottom rounded-[0.14em]"
style={{ backgroundColor: tone }}
initial={false}
animate={{ scaleY: on ? 1 : UNDERLINE }}
transition={reduce ? { duration: 0 } : { type: "spring", damping: 30, stiffness: 320 }}
/>
<span className="relative">{children}</span>
</a>
);
}
export default function LinkUnderlineThatFillsIntoAHighlightOnHover() {
const reduce = useReducedMotion();
// The preview fills each headline link in turn until the visitor points at or tabs into the block.
const [demo, setDemo] = useState(0);
const [playing, setPlaying] = useState(true);
useEffect(() => {
if (!playing || reduce) return;
const id = window.setInterval(() => setDemo((d) => (d + 1) % 4), DEMO_MS);
return () => window.clearInterval(id);
}, [playing, reduce]);
const lit = (i: number) => playing && !reduce && demo === i;
return (
<section className="bg-white px-6 py-20 text-slate-900 sm:py-28" onPointerEnter={() => setPlaying(false)} onFocus={() => setPlaying(false)}>
<div className="mx-auto max-w-5xl">
<div className="flex items-center justify-between border-b-2 border-slate-900 pb-4">
<p className="font-serif text-2xl font-bold tracking-tight">The Sunday Ledger</p>
<p className="font-mono text-xs uppercase tracking-widest text-slate-600">Issue 212</p>
</div>
<h2 className="mt-12 font-serif text-3xl leading-[1.45] tracking-tight sm:text-5xl sm:leading-[1.4]">
This week: why{" "}
<FillLink tone={TONES.blue} active={lit(0)}>
corner shops are back
</FillLink>
, the quiet return of{" "}
<FillLink tone={TONES.rose} active={lit(1)}>
the paper map
</FillLink>
, and a reader asks whether to{" "}
<FillLink tone={TONES.green} active={lit(2)}>
rent forever
</FillLink>
.
</h2>
<div className="mt-12 grid gap-10 border-t border-slate-200 pt-10 md:grid-cols-[1fr_auto] md:items-end">
<div>
<p className="max-w-lg text-lg leading-relaxed text-slate-600">
One email every Sunday at seven. Five stories worth your morning and nothing else. Free to read, paid for by members.{" "}
<span className="font-semibold text-slate-900">
<FillLink tone={TONES.ink} active={lit(3)}>
Subscribe
</FillLink>
</span>
</p>
</div>
<nav aria-label="Sections">
<ul className="flex flex-wrap gap-x-6 gap-y-1 text-base font-semibold">
{sections.map((s) => (
<li key={s.label} className="flex min-h-[40px] items-center">
<FillLink tone={s.tone}>{s.label}</FillLink>
</li>
))}
</ul>
</nav>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/link-underline-that-fills-into-a-highlight-on-hover.json
The link contains an absolutely positioned span that covers it fully. At rest the span is scaled down vertically from the bottom edge to 8 percent of its height, which looks like an underline. On hover it scales back to full height. Only `transform` changes, so the motion is smooth and nothing reflows.
The link is `inline-block` so the fill has one box to cover, which means a long link moves to the next line as a block. That reads well for phrases of a few words. For very long links, shorten the linked text.
Yes. Replace the `motion.span` with a plain span and use `scale-y-[0.08] group-hover:scale-y-100 transition-transform origin-bottom` with `group` on the anchor. You lose the spring but keep the effect.
A timer steps through the headline links so the effect is visible in a static preview and on touch screens. It stops as soon as a pointer enters the section or a link receives focus.
Adapted from Underline To Background in Fancy Components, used under its open-source licence. Licences and credits
Related: Marker Highlight Text That Sweeps Across Phrases, Editorial Hero with Sliding Letters and Drawn Underline, Magnetic Button That Follows the Pointer.
More text 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.