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.
| Section | Navigation and lists |
|---|---|
| Techniques | Pointer interaction, Text animation |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Simple 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.
More elements
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.