Text

Link Underline

Underline motion for inline body links — slide, center, wipe or thicken — that survives line wraps, descenders, reduced motion and forced colors.

Preview in your theme

Loading preview…

import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"

/**
 * Link Underline — underline motion for inline links inside body copy.
 *
 * Two constraints decide the whole implementation:
 *
 * 1. Inline links wrap. A pseudo-element rule (`::after` positioned at the
 *    bottom of the link) is laid out against the *bounding box* of the
 *    fragments, so a wrapped link gets one rule instead of one per line.
 *    Every moving part here is a `background-image` layer plus
 *    `box-decoration-break: clone`, which gives each line box its own

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/link-underline.json

Prompt

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 boxbox-decoration-break: clone is 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 why background-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-visible flip --lu-grow / --lu-wipe / --lu-thickness and 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) and thicken keeps 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.

On This Page