Link Underline
Underline motion for inline body links — slide, center, wipe or thicken — that survives line wraps, descenders, reduced motion and forced colors.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/link-underline.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LinkUnderline" component for inline links
in body copy (radix-ui Slot for asChild; CSS only, no animation library).
Contract
- forwardRef anchor extending React.AnchorHTMLAttributes<HTMLAnchorElement>.
- Props: variant = "slide" | "center" | "wipe" | "thicken" (default "slide"),
color = "current" | "primary" | "chart-1".."chart-5" (default "current"),
duration in ms (default 320, clamped to >= 0, non-finite falls back),
asChild (default false).
- Type href as REQUIRED unless asChild is set:
AnchorProps & Own & ({ asChild: true; href?: never } | { asChild?: false; href: string })
A link component that compiles without a destination is exactly how href="#"
placeholders get shipped; make the compiler refuse it instead.
- color maps to var(--primary) / var(--chart-N) internally and paints ONLY the
rule. The component never sets the text color — the surrounding prose owns it.
That is also why the rule can never be optional (see Behavior).
Behavior
- All four variants are underlined AT REST; hover/focus changes the rule, it
never creates it. This is a deliberate accessibility decision, not a style
choice: the component does not control the text color, so an "underline
appears on hover" mode would leave the link identified by color alone at rest,
which fails WCAG 1.4.1 Use of Color. Do not add such a variant; if a design
needs hover-only underlines, that design has to supply a different non-color
cue (icon, weight, position) at rest.
- Cross-line correctness is the hard part. An inline link wraps into several
line boxes, and a rule drawn with an ::after pseudo-element is positioned
against the bounding box of all the fragments, so only one line ends up
underlined. Draw every rule as a background-image layer on the link itself and
set box-decoration-break: clone (plus the -webkit- prefix), which gives each
line box its own background positioning area. Every line then gets its own
rule, and animating background-size / background-position animates all lines
at once with zero reflow.
- Geometry is driven by CSS custom properties that hover/focus-visible flip, so
a single inline style holds the layer stack and a single transition list
covers all variants:
--lu-grow 0% -> 100% (slide, center)
--lu-wipe 100% -> 0% (wipe)
--lu-thickness rest -> active (thicken)
slide : two layers, both at background-position "0 100%"; the emphasis layer
is `var(--lu-grow) var(--lu-active)`, the resting hairline is
`100% var(--lu-rest)`.
center : identical, but the emphasis layer sits at "50% 100%" so it opens
outward from the middle.
wipe : one gradient three times as wide, bar | gap | bar, with
background-size "300% var(--lu-active)". Sliding background-position-x
from 100% to 0% walks the visible bar off the right edge while its
replacement enters from the left. A resting hairline underneath means
the link is never rule-less while the gap passes over it. Note the
consequence: because that bar is on screen at rest, wipe rests at the
emphasis thickness, not at the hairline the other variants rest on.
thicken: no background layers at all. text-decoration-line: underline with
text-decoration-thickness: var(--lu-thickness) and
text-underline-offset: var(--lu-offset). It is the only variant that
stays inside the text-decoration system and therefore the only one
that keeps native text-decoration-skip-ink.
- Descenders. A background bar cannot skip ink — there is no equivalent of
text-decoration-skip-ink for background-image — so the bar variants clear the
descenders instead: give them padding-bottom: var(--lu-clearance) (0.08em).
Inline padding does not change line boxes but does extend the background
positioning area, so a "0 100%" bar moves down by exactly that much. Without
it the rule overlaps the tails of j/g/p/q/y by about a pixel at 16px. Keep
text-decoration-skip-ink: auto on the thicken variant and let the glyphs punch
through — that is the variant to reach for when the rule must sit tight under
text with a lot of descenders.
- Focus. focus-visible gets the same finished rule as hover PLUS
outline-2 outline-offset-2 outline-ring. Hover never draws an outline, so the
two states are told apart by something other than "the rule looks slightly
different" — which is the mistake to avoid when the hover effect is itself an
underline change.
- prefers-reduced-motion. motion-reduce:transition-none sets transition-property
to none. The resting rule and the hover/focus end state are untouched: the
underline is information, only the sweep is decoration. Never gate the rule
itself on motion.
- Forced colors and print. Both drop background images (forced-colors computes
background-image to none; printing omits backgrounds by default), which would
leave the bar variants with no underline at all. Give them
"no-underline print:underline forced-colors:underline" so a real
text-decoration comes back in exactly those two environments.
- Long link text. Link text is often a bare URL or identifier; add break-words so
one unbreakable token cannot drag the page into horizontal scroll.
- asChild renders through Slot so next/link (or any router link) can carry the
href and keep client-side navigation; the className and the inline style are
merged onto the child.
Rendering & styling
- Semantic tokens only: currentColor, var(--primary), var(--chart-1..5),
outline-ring. No hex, no rgb(), no oklch() in the component.
- Thicknesses are relative so the rule tracks the type size:
--lu-rest max(1px, 0.055em), --lu-active max(2px, 0.11em),
--lu-offset 0.16em, --lu-clearance 0.08em.
- Merge className with cn() and spread the consumer's style last so per-instance
overrides of the custom properties win.
- The link text itself is untouched DOM: selectable, searchable, announced
normally. Nothing is aria-hidden because nothing decorative is a real node.
Customization levers
- variant is the tone: slide for editorial prose, center for marketing copy,
wipe when you want the link to feel like it re-draws itself, thicken when the
typography has to stay honest (descenders, small sizes, print-heavy content).
- The four custom properties are the tuning surface and can be overridden per
instance via style or globally via a wrapper class: raise --lu-rest for a
heavier resting rule, raise --lu-active for a chunkier hover, lower
--lu-offset to pull the thicken rule closer to the baseline, raise
--lu-clearance if your typeface has unusually deep descenders.
- duration 200-400ms reads as a UI transition; above ~600ms the sweep starts to
feel like an animation the reader has to wait for.
- color gives an editorial palette when a section needs its own link accent; keep
one accent per page and leave body links on "current" so the rule inherits the
text contrast for free.
- To follow a brand's link color, set the text color on the surrounding prose
(or on the link's className) and leave color="current" — the rule follows.Concepts
- One rule per line box —
box-decoration-break: cloneis the whole trick: it stops the browser treating a wrapped inline as one sliced box and gives each fragment its own background area, which is whybackground-size: 0% → 100%grows a rule on every line at once instead of filling line one before line two. - Underline at rest, not on hover — the component never sets the text color, so it cannot know whether color alone distinguishes the link. Keeping a rule in the resting state is what makes it safe to drop into arbitrary prose; the motion is an emphasis layer on top of an already-valid link.
- Custom properties as the state machine — hover and
focus-visibleflip--lu-grow/--lu-wipe/--lu-thicknessand nothing else, so all four variants share one transition list and one inline style; the transition still runs, because the dependent property's computed value is what changes. - Skip-ink is a text-decoration-only feature — a background bar has no way to open gaps around descenders, so the geometric variants buy clearance with inline
padding-bottom(which extends the background box without touching line boxes) andthickenkeeps the native behaviour instead. - Focus needs a channel hover does not use — when the hover effect is an underline change, a focus style built from the same channel is unreadable; the outline ring is the second channel, and it appears only for
focus-visible. - Decoration degrades, information does not — reduced motion kills the transition, forced-colors and print kill the background layers, and in all three cases a real underline is still there because the fallback lives in
text-decoration, not in the animated layer.