Copied!
Navigation and lists

Line hover links

Six text links with different CSS line hover animations: underline grow, strike-through, wavy SVG, double line, marker highlight and an offset slide.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Line hover links" for my site using the Enrichr Library element at https://enrichr.dev/elements/line-hover-links.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/line-hover-links.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- duration: 450
- thickness: 2
- size: 1.35

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/line-hover-links.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-llh">
  <div class="ef-llh__wrap"><div class="ef-llh__list"></div></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-llh { width: 100%; }
.ef-llh__wrap { --ef-acc: #e6ff55; --ef-d: 450ms; --ef-t: 2px; --ef-fs: 1.35rem; box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.4rem; border-radius: 1rem; display: flex; align-items: center; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-llh__list { width: 100%; display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1.4rem 1.6rem; }
.ef-llh__row { display: flex; flex-direction: column; gap: 0.45rem; align-items: flex-start; }
.ef-llh__tag { font-size: 0.62rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.55; }
.ef-llh__a { position: relative; display: inline-block; padding: 0.1em 0; font-size: var(--ef-fs); font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; color: inherit; text-decoration: none; outline: none; isolation: isolate; }
.ef-llh__a:focus-visible { outline: 2px solid var(--ef-acc); outline-offset: 6px; border-radius: 2px; }
.ef-llh__a::before, .ef-llh__a::after { content: ""; position: absolute; pointer-events: none; transition: transform var(--ef-d) cubic-bezier(.2,.8,.2,1), opacity var(--ef-d), height var(--ef-d) cubic-bezier(.2,.8,.2,1); }

/* 1 underline grow */
.ef-llh__a--grow::after { left: 0; right: 0; bottom: -0.05em; height: var(--ef-t); background: var(--ef-acc); transform: scaleX(0); transform-origin: 0 50%; }
.ef-llh__a--grow:hover::after, .ef-llh__a--grow:focus-visible::after { transform: scaleX(1); }

/* 2 strike-through */
.ef-llh__a--strike::after { left: -0.08em; right: -0.08em; top: 52%; height: var(--ef-t); background: var(--ef-acc); transform: scaleX(0); transform-origin: 100% 50%; }
.ef-llh__a--strike:hover::after, .ef-llh__a--strike:focus-visible::after { transform: scaleX(1); transform-origin: 0 50%; }

/* 3 wavy svg */
.ef-llh__a--wave { padding-bottom: 0.3em; }
.ef-llh__wavesvg { position: absolute; left: 0; bottom: 0; width: 100%; height: 0.32em; overflow: visible; pointer-events: none; }
.ef-llh__wavesvg path { fill: none; stroke: var(--ef-acc); stroke-width: var(--ef-t); stroke-linecap: round; vector-effect: non-scaling-stroke; stroke-dasharray: 1000; stroke-dashoffset: 1000; transition: stroke-dashoffset calc(var(--ef-d) * 1.6) cubic-bezier(.2,.8,.2,1); }
.ef-llh__a--wave:hover path, .ef-llh__a--wave:focus-visible path { stroke-dashoffset: 0; }

/* 4 double line */
.ef-llh__a--double::before, .ef-llh__a--double::after { left: 0; right: 0; height: var(--ef-t); background: var(--ef-acc); transform: scaleX(0); }
.ef-llh__a--double::before { top: 0; transform-origin: 100% 50%; }
.ef-llh__a--double::after { bottom: 0; transform-origin: 0 50%; }
.ef-llh__a--double:hover::before, .ef-llh__a--double:hover::after, .ef-llh__a--double:focus-visible::before, .ef-llh__a--double:focus-visible::after { transform: scaleX(1); }

/* 5 marker highlight */
.ef-llh__a--marker { padding: 0.1em 0.15em; margin: 0 -0.15em; transition: color var(--ef-d); }
.ef-llh__a--marker::before { left: 0; right: 0; bottom: 0.05em; height: 0.18em; background: var(--ef-acc); z-index: -1; opacity: 0.9; }
.ef-llh__a--marker:hover::before, .ef-llh__a--marker:focus-visible::before { height: 92%; }
.ef-llh__a--marker:hover, .ef-llh__a--marker:focus-visible { color: #0c1e18; }

/* 6 offset line */
.ef-llh__a--offset { transition: transform var(--ef-d) cubic-bezier(.2,.8,.2,1); }
.ef-llh__a--offset::after { left: 0; right: 0; bottom: -0.3em; height: var(--ef-t); background: var(--ef-acc); transform: translateX(-100%); opacity: 0; }
.ef-llh__a--offset:hover::after, .ef-llh__a--offset:focus-visible::after { transform: translateX(0.5em); opacity: 1; }
.ef-llh__a--offset:hover, .ef-llh__a--offset:focus-visible { transform: translateX(-0.2em); }

@media (prefers-reduced-motion: reduce) {
  .ef-llh__a, .ef-llh__a::before, .ef-llh__a::after, .ef-llh__wavesvg path { transition: none !important; }
  .ef-llh__a--offset:hover, .ef-llh__a--offset:focus-visible { transform: none; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Line hover links: six text links, each with a different CSS-only line animation on hover and keyboard focus
// (underline grow, strike-through, wavy SVG, double line, marker highlight, offset). The script only builds the
// links and writes CSS variables (--ef-acc --ef-d --ef-t --ef-fs); the animation itself lives in the stylesheet.
// mount(root, { background, accent, duration (ms), thickness (px), size (rem) })
function mount(root, o) {
  var wrap = root.querySelector(".ef-llh__wrap");
  var list = root.querySelector(".ef-llh__list");
  var opts = {}, offs = [], built = false;
  var NS = "http://www.w3.org/2000/svg";
  var DATA = [
    ["grow", "Underline grow", "Journal"],
    ["strike", "Strike through", "Archive"],
    ["wave", "Wavy line", "Playground"],
    ["double", "Double line", "Showcase"],
    ["marker", "Marker highlight", "Manifesto"],
    ["offset", "Offset slide", "Contact"]
  ];

  function clear() { offs.forEach(function (f) { f(); }); offs = []; }
  function build() {
    clear();
    list.textContent = "";
    DATA.forEach(function (d) {
      var row = document.createElement("div"); row.className = "ef-llh__row";
      var tag = document.createElement("span"); tag.className = "ef-llh__tag"; tag.textContent = d[1];
      var a = document.createElement("a"); a.className = "ef-llh__a ef-llh__a--" + d[0]; a.href = "#";
      a.appendChild(document.createTextNode(d[2]));
      if (d[0] === "wave") {
        var svg = document.createElementNS(NS, "svg");
        svg.setAttribute("class", "ef-llh__wavesvg");
        svg.setAttribute("viewBox", "0 0 100 10");
        svg.setAttribute("preserveAspectRatio", "none");
        svg.setAttribute("aria-hidden", "true");
        var p = document.createElementNS(NS, "path");
        p.setAttribute("d", "M0 5 Q 6.25 -2 12.5 5 T 25 5 T 37.5 5 T 50 5 T 62.5 5 T 75 5 T 87.5 5 T 100 5");
        svg.appendChild(p); a.appendChild(svg);
      }
      function ck(e) { e.preventDefault(); }
      a.addEventListener("click", ck);
      offs.push(function () { a.removeEventListener("click", ck); });
      row.appendChild(tag); row.appendChild(a);
      list.appendChild(row);
    });
    built = true;
  }
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", duration: 450, thickness: 2, size: 1.35 }, opts, next || {});
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--ef-d", Math.max(0, opts.duration) + "ms");
    wrap.style.setProperty("--ef-t", Math.max(1, opts.thickness) + "px");
    wrap.style.setProperty("--ef-fs", Math.max(0.8, opts.size) + "rem");
    if (!built) build();
  }
  update(o);
  return { update: update, destroy: function () { clear(); list.textContent = ""; built = false; } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","duration":450,"thickness":2,"size":1.35});

How it works

Each link is a normal anchor with a variant class. The line is drawn by the ::before and ::after pseudo-elements, which start at scaleX(0) and grow to scaleX(1) on hover and focus-visible, with the transform origin deciding the direction. Strike-through places the line at mid height, double line uses both pseudo-elements with opposite origins, marker highlight animates the height of a line behind the text and flips the text colour, and offset slides a detached line in under the word while the word nudges aside. The wavy line is an inline SVG path drawn by animating stroke-dashoffset from a long dash length to zero. The script only builds the links and writes four CSS variables (accent, duration, thickness, size).

Accessibility and performance

Every effect is attached to :focus-visible as well as :hover, so keyboard users get the same animation plus an outline. The decorative SVG is aria-hidden and the lines are pseudo-elements, so screen readers only see the link text. With prefers-reduced-motion the transitions are removed and the line simply appears. Check that the accent colour has enough contrast against the background, and against the text for the marker variant.

Only transform, opacity and a single height transition on one pseudo-element are animated, and only while a link is hovered. There are no listeners other than a click guard on the demo links, no rAF and no layout reads.

SectionNavigation and lists
TechniquesPointer interaction, Text animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired bySimple CSS Line Hover Animations for Links by Manoela Ilic on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Hover font variantsA text index whose rows flip through serif, mono, outline, wide and italic styles while hovered or focused. Distorted link hoverBig links whose letters and underline ripple like heat haze on hover, using an animated SVG turbulence displacement filter. Gooey text hoverA menu of big words with a gooey blob that stretches and follows the pointer from row to row while the active word turns bold.

FAQ

Can I use one effect without the others?

Yes. Copy the base .ef-llh__a rules plus the one variant block you want, and add the variant class to your anchor.

Why use pseudo-elements instead of text-decoration?

Pseudo-elements can be scaled and positioned freely, so the line can grow from either side, sit at mid height or be offset. text-decoration cannot animate its length.

How do I change the wave shape?

Edit the d attribute of the path in the script. Keep the stroke-dasharray in the stylesheet longer than the line so the dash animation still draws it fully.