Introduction
In this article we build an animated SVG hover lines effect — a tactile, visually satisfying button where 120 vertical SVG lines react to a mouse hover with a staggered wave-like collapse. When the cursor enters the button, odd-numbered lines scale upward slightly while even-numbered lines flip downward and shrink, and each line triggers a fraction of a second after the one before it, creating a ripple that sweeps across the full width. No JavaScript drives the animation — only CSS transitions, nth-child selectors, and a single JS loop that generates the line elements so you never hand-write 120 identical tags.
The effect relies on three CSS mechanisms working together: transform-origin set to the top of each line so scaleY stretches or collapses from that anchor point, transition-delay incremented per child so each line fires slightly after the last, and the adjacent sibling combinator (~) so the hover state on the anchor element reaches across to the SVG lines without any JavaScript event listeners.
How to use the CSS adjacent sibling combinator (~) to trigger styles on elements that follow a hovered element — how transform-origin changes the anchor point of a scaleY transform — how to stagger CSS transition delays across 120 nth-child selectors — how to generate repetitive SVG markup with a small JS loop instead of hand-writing it — and how to layer an anchor element over an SVG so both receive pointer events correctly via z-index.
Step 1 — CSS Custom Properties
All visual values are expressed as custom properties so the entire effect can be recoloured or resized by changing a handful of tokens at the top of the stylesheet. The line count, total delay, and scale amounts are the three values most worth exposing as tokens — they are the primary levers for tuning the feel of the animation.
/* all visual values derive from these tokens */
:root {
--btn-width: 120px; /* SVG and anchor width */
--btn-height: 40px; /* SVG and anchor height */
--line-count: 120; /* number of vertical lines across the width */
--line-color: #7c3aed; /* stroke colour of every line */
--delay-total: 0.3s; /* total stagger spread across all lines */
--transition-dur: 0.3s; /* duration of each individual line transition */
--scale-odd: 1.2; /* odd lines grow slightly upward on hover */
--scale-even: -0.2; /* even lines flip and shrink on hover */
--text-color: #e8e0f0; /* label colour at rest */
--text-hover: #7c3aed; /* label colour on hover */
}Storing the total stagger spread as one token and computing per-line delay as (index × total / count) in JavaScript means you only change one number to speed up or slow down the whole ripple. If you stored a per-line step instead, changing the line count would require updating the step separately to keep the total spread the same.
Step 2 — The Markup
The component needs only two elements inside a wrapper: an anchor tag that acts as the visible label and hover target, and an SVG element that holds the lines. The anchor must come first in source order because the CSS sibling combinator (a:hover ~ svg) only reaches forward to later siblings, never backward. The SVG lines are generated by JavaScript so the HTML stays clean — no hand-written line elements.
<div class="btn-wrap">
<!-- anchor comes first — CSS sibling selector reaches forward to the SVG -->
<a href="#" class="btn-label">ADUOK</a>
<!-- SVG is populated by JS — no hand-written line elements needed -->
<svg id="lineSvg"
viewBox="0 0 120 40"
preserveAspectRatio="none"
width="120"
height="40">
</svg>
</div>| Element | Role | Pointer Events |
|---|---|---|
| .btn-wrap | Relatively positioned container — anchors both the label and SVG to the same coordinate space | Default |
| .btn-label | Hover trigger and visible text label — z-index: 2 keeps it above the SVG for click events | Receives hover, triggers ~ svg line styles |
| svg | Passive container for the line elements — positioned absolute so it underlaps the anchor | None (pointer-events: none recommended) |
| line | One vertical stroke — transform-origin: 50% 0% anchors scaleY to the top of the line | None |
Step 3 — Generating 120 Lines with JavaScript
Rather than writing 120 identical line elements by hand, a small JS loop creates them programmatically and appends them to the SVG. Each line runs the full height of the SVG (y1=0, y2=40) and is offset one pixel to the right of the previous one (x1=i, x2=i). The transition delay is also set here per element, computed as a fraction of the total stagger spread so the wave takes exactly --delay-total seconds to cross all lines regardless of how many lines there are.
const svg = document.getElementById('lineSvg');
const total = 120; // matches --line-count token
for (let i = 1; i <= total; i++) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
// x position: one pixel per line, spanning the full 120px width
line.setAttribute('x1', i);
line.setAttribute('x2', i);
// y position: full height of the SVG viewBox
line.setAttribute('y1', 0);
line.setAttribute('y2', 40);
// staggered delay: line i fires at (i / total) × 0.3s after hover
// toFixed(5) avoids floating-point noise in the computed value
line.style.transitionDelay = `${((i * 0.3) / total).toFixed(5)}s`;
svg.appendChild(line);
}SVG elements live in the SVG namespace (http://www.w3.org/2000/svg), not the HTML namespace. Using createElement("line") creates an HTML element with that tag name, which has no SVG rendering behaviour. createElementNS with the SVG namespace URI creates a real SVG line element that the browser renders as a vector stroke.
Step 4 — Layering the Anchor Over the SVG
Both the anchor and the SVG must occupy exactly the same rectangle so the text sits centred over the lines. The wrapper uses position: relative to establish a coordinate origin, and both children use position: absolute with inset: 0 to fill it completely. The anchor gets z-index: 2 so it sits above the SVG and receives pointer events for the hover trigger and click. The SVG sits at the default z-index layer below — it is purely visual and does not need to capture any pointer events.
.btn-wrap {
position: relative;
width: var(--btn-width); /* 120px */
height: var(--btn-height); /* 40px */
transform: scale(3); /* scale up for demo visibility */
}
.btn-label {
position: absolute;
inset: 0;
z-index: 2; /* sits above SVG — receives the hover */
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
font-family: "Bebas Neue", sans-serif;
font-size: 22px;
letter-spacing: 0.12em;
color: var(--text-color); /* #e8e0f0 at rest */
transition: transform 0.3s, color 0.3s;
}
svg {
position: absolute;
inset: 0;
overflow: visible; /* lines may extend slightly past the box */
}Step 5 — scaleY and transform-origin
The visual effect is driven by two scaleY transforms applied to alternating lines on hover. Odd-indexed lines scale to 1.2 — they grow 20% taller from their top anchor point. Even-indexed lines scale to -0.2 — the negative value flips them upside-down and collapses them to 20% of their original height. Combined with a translateX(-1px) on the even lines, this creates a subtle interleaved pinch-and-stretch pattern rather than a uniform collapse.
svg line {
stroke: var(--line-color); /* #7c3aed */
stroke-width: 1px;
/* anchor scaleY to the top of the line, not its centre */
transform-origin: 50% 0%;
transition: transform var(--transition-dur), opacity var(--transition-dur);
}
/* odd lines grow upward from their top anchor */
.btn-label:hover ~ svg line:nth-child(odd) {
transform: scaleY(1.2);
opacity: 0.15;
}
/* even lines flip and shrink — negative scaleY inverts the direction */
.btn-label:hover ~ svg line:nth-child(even) {
transform: scaleY(-0.2) translateX(-1px);
opacity: 0.15;
}transform-origin: 50% 0% is the detail that makes the effect feel grounded. Without it, scaleY collapses lines toward their vertical centre and the animation looks like a generic shrink. With it, every line pivots from its top edge — the lines appear to fold down from a fixed rail, like blinds closing.
Step 6 — The Ripple via Per-Line transition-delay
The ripple wave is produced entirely by transition-delay. Each line gets a delay equal to its index divided by the total count, multiplied by the total stagger duration. Line 1 fires at 0.0025s, line 60 at 0.15s, and line 120 at 0.3s. Because this delay is set as an inline style by the JavaScript loop, no nth-child CSS rules are needed for the timing — the CSS only describes what happens, the JS describes when each line starts.
// inside the generation loop — one delay per line
// formula: (lineIndex × totalStagger) / lineCount
// result: evenly distributed delays from ~0s to totalStagger
line.style.transitionDelay = `${((i * 0.3) / total).toFixed(5)}s`;
// example values produced:
// line 1 → 0.00248s
// line 30 → 0.07438s
// line 60 → 0.14876s
// line 90 → 0.22314s
// line 120 → 0.29752sCSS transition-delay applies to every transition on the element — both the hover-in and the hover-out. This means the ripple wave sweeps left-to-right when the cursor enters and also sweeps left-to-right when it leaves. If you want the exit wave to sweep right-to-left, you would need to swap the delays on mouse-leave using JavaScript, which trades the pure-CSS simplicity for a mirrored exit effect.
Step 7 — The Adjacent Sibling Combinator
The entire SVG animation is triggered without a single JavaScript event listener. The CSS selector .btn-label:hover ~ svg line targets any line element inside an SVG that follows a hovered .btn-label in the same parent. The ~ (general sibling) combinator matches any later sibling, not just the immediately adjacent one, so the SVG does not need to be the very next element after the anchor — any later sibling qualifies.
/* ~ is the general sibling combinator */
/* reads: when .btn-label is hovered, */
/* find any svg that is a later sibling of .btn-label, */
/* then target all line elements inside that svg */
.btn-label:hover ~ svg line:nth-child(odd) { transform: scaleY(1.2); opacity: 0.15; }
.btn-label:hover ~ svg line:nth-child(even) { transform: scaleY(-0.2) translateX(-1px); opacity: 0.15; }
/* the anchor also changes colour on hover — independent transition */
.btn-label:hover {
transform: scale(1.1);
color: var(--text-hover); /* #7c3aed */
}The adjacent sibling combinator is what makes this a zero-JavaScript animation. The hover state on the anchor propagates through CSS to the SVG lines — no mouseover listeners, no classList.add, no requestAnimationFrame. The browser handles every frame of every line transition natively on the GPU.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --btn-width | 120px | Width of the wrapper, anchor, and SVG viewBox. Increase to spread the lines over a wider button |
| --btn-height | 40px | Height of the wrapper and SVG. Taller values give the scaleY transform more visual travel distance |
| --line-count (JS total) | 120 | Number of vertical lines generated. More lines = denser curtain. Fewer lines = visible gaps between strokes |
| --delay-total (JS 0.3) | 0.3s | Total time for the stagger wave to cross all lines. Lower for a snappy ripple, higher for a slow sweep |
| --transition-dur | 0.3s | Duration of each individual line transform. Should roughly match --delay-total for a smooth continuous wave |
| --scale-odd | 1.2 | scaleY applied to odd lines on hover. Values above 1 grow the line; below 1 shrink it; negative values flip it |
| --scale-even | -0.2 | scaleY applied to even lines on hover. Negative creates the flip effect that interleaves with the odd growth |
| transform-origin | 50% 0% | Anchor point for scaleY. 50% 0% pivots from the top. 50% 50% pivots from centre. 50% 100% pivots from bottom |
| opacity on hover | 0.15 | Line opacity when hovered. Lower values make the lines nearly invisible, letting the label dominate |
| transform: scale(3) on .btn-wrap | 3 | Demo scale multiplier only — remove or reduce for production use at normal button size |
Full Source Code
Save the following as svg-hover-lines.html and open in any modern browser. The SVG sibling selector and CSS transitions are supported in all evergreen browsers. The JS loop uses createElementNS which has been available since IE9. No build step, no dependencies, no frameworks required.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/svg-hover-lines.htmlOne wrapper div, one anchor, one SVG, a 10-line JS loop, and roughly 25 lines of CSS. The entire ripple wave effect — 120 lines, staggered delays, alternating transforms, colour transitions — runs natively on the GPU without a single animation frame computed in JavaScript.