Skip to content

Components / Text

Underline Hover

A link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it. One background gradient, no pseudo-element, wraps across lines. Pure CSS; triggered by the link around it.

Live demo

Enters from the left, leaves to the right, with one gradient and no pseudo-element.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import UnderlineHover from '@moonarc/core/UnderlineHover';
---
<a href="/docs/"><UnderlineHover>Read the docs</UnderlineHover></a>
<UnderlineHover as="a" href="/pro/" thickness="2px" color="var(--ma-ink)">Pro</UnderlineHover>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/underline-hover.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

UnderlineHover.astro
---
/**
 * UnderlineHover — the line enters from the left and leaves to the right.
 * One background gradient: its size grows on hover while anchored left,
 * and shrinks on leave while anchored right, so the exit runs the same
 * direction as the entrance. Works on the element or from the link or
 * button around it. Zero JS.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Line thickness, any CSS length. */
  thickness?: string;
  /** Gap between the text and the line. */
  offset?: string;
  /** Line colour. */
  color?: string;
}

const { as: Tag = 'span', thickness = '0.08em', offset = '0.12em', color = 'currentColor', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-underline-w:${thickness}`, `--ma-underline-offset:${offset}`, `--ma-underline-color:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-underline', className]} style={vars} {...rest}><slot /></Tag>

<style is:global>
  @layer components {
    :where(.ma-underline) {
      text-decoration: none;
      padding-bottom: var(--ma-underline-offset, 0.12em);
      background-image: linear-gradient(var(--ma-underline-color, currentColor), var(--ma-underline-color, currentColor));
      background-repeat: no-repeat;
      background-size: 0% var(--ma-underline-w, 0.08em);
      background-position: 100% 100%;
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      transition: background-size var(--ma-duration-fast) var(--ma-ease-out);
    }
    @media (hover: hover) {
      :where(.ma-underline:hover),
      :where(:is(a, button):hover .ma-underline) {
        background-size: 100% var(--ma-underline-w, 0.08em);
        background-position: 0% 100%;
        transition-duration: var(--ma-duration);
        transition-timing-function: var(--ma-ease);
      }
    }
    :where(.ma-underline:focus-visible),
    :where(:is(a, button):focus-visible .ma-underline) {
      background-size: 100% var(--ma-underline-w, 0.08em);
      background-position: 0% 100%;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-underline) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
asHTMLTag'span'Element to render. Use a or wrap it in one.
thicknessstring'0.08em'Line thickness.
offsetstring'0.12em'Gap between the text and the line.
colorstring'currentColor'Line colour.

Reduced motion

The line appears and disappears without sliding.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: underline hover effects (Animata, uiverse) · Framer Motion layout underlines. See the migration table.

Swells the letter under the cursor to the heaviest weight and lifts it; its neighbours follow at half and a quarter, and everything settles when the pointer leaves.

every browserhover

A highlighter sweeps across the phrases you marked, one after another, when the sentence scrolls into view.

every browserscroll

A label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves.

every browserhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown