Swish UI
All componentsText Free

Underline draw link

A link's underline draws in from the left on hover and slides away to the right when the pointer leaves.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import styles from "./demo.module.css";

export default function Demo() {
  const stay = (event: React.MouseEvent) => event.preventDefault();
  return (
    <div className={styles.root}>
      <div className={styles.card}>
        <p className={styles.text}>
          Before you arrive, read the <a href="#" className={styles.link} onClick={stay}>house rules</a>, check the{" "}
          <a href="#" className={styles.link} onClick={stay}>cancellation policy</a> or{" "}
          <a href="#" className={styles.link} onClick={stay}>message your host</a> about late check in.
        </p>
        <nav className={styles.footer} aria-label="Help">
          {["Help centre", "Payments", "Contact"].map((label) => (
            <a key={label} href="#" className={`${styles.link} ${styles.plain}`} onClick={stay}>{label}</a>
          ))}
        </nav>
      </div>
    </div>
  );
}

demo.module.css

.root { container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.card { display: grid; gap: 18px; max-width: 34em; padding: clamp(18px, 6cqi, 40px); border-radius: 16px; background: var(--white); border: 1px solid var(--line); }
.text { font-size: clamp(16px, min(4.4cqi, 7cqh), 22px); line-height: 1.6; }
.link {
  color: var(--accent-text);
  font-weight: 600;
  text-decoration: none;
  background:
    linear-gradient(currentColor, currentColor) no-repeat 100% 100% / 0% 2px,
    linear-gradient(var(--accent-soft), var(--accent-soft)) no-repeat 0 100% / 100% 2px;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  padding-bottom: 2px;
  transition: background-size 0.4s var(--out);
}
.link:hover, .link:focus-visible { background-position: 0 100%, 0 100%; background-size: 100% 2px, 100% 2px; }
.plain { color: var(--ink); font-size: 14px; background-size: 0% 2px, 0% 2px; }
.footer { display: flex; flex-wrap: wrap; gap: 4px 20px; padding-top: 14px; border-top: 1px solid var(--line); }
.footer .link { display: inline-flex; align-items: flex-end; min-height: 44px; padding-bottom: 10px; background-position: 100% calc(100% - 8px), 0 calc(100% - 8px); }
.footer .link:hover, .footer .link:focus-visible { background-position: 0 calc(100% - 8px), 0 calc(100% - 8px); }
@media (prefers-reduced-motion: reduce) { .link { transition: none; } }

Use it for

  • Inline links in marketing copy and articles
  • Footer and secondary navigation

Avoid

  • Links with no resting underline in body text, colour alone is not enough
  • Buttons, which need a stronger hover state

How it works

The underline is a linear-gradient background in currentColor, 2px tall and 0 percent wide, anchored at background-position right. On hover the position flips to left and background-size grows to 100 percent, so the line grows from the left. On leave the position flips back to right before the size shrinks, so it retracts toward the right. Only background-size is transitioned, and box-decoration-break: clone gives each wrapped line its own stroke.

Accessibility

Inline links keep a faint resting underline as a second background layer, so they are not marked by colour alone. Focus-visible draws the same line as hover and footer links have a 44px hit area. Reduced motion shows the line instantly.

Found on