26 CSS Link Effects07 / 26

Pure CSSMIT licensed

CSS Wavy / Squiggly Underline Hover Effect

Squiggles, three ways: the one-line native route (text-decoration-style: wavy fading in — wraps across lines for free), a marching squiggle that scrolls under the link for as long as you hover, and a rainbow squiggle that drifts through hues while it marches. The SVG wave is a 200-byte data-URI, no assets.

Published Updated

Live Demo
Try it

The code

<section class="cle-07" aria-label="Wavy underline hover demo">
  <div class="cle-07__stage">
    <p class="cle-07__prose">Field notes from the print room — <a href="#a" class="cle-07__native">the overprint experiments</a> ran long, but the riso drum forgave us. Next week we ink <a href="#a2" class="cle-07__native">a two-color zine</a>.</p>
    <div class="cle-07__row">
      <div class="cle-07__var"><a href="#b" class="cle-07__march">Marching squiggle</a><small class="cle-07__cap">B · scrolls while hovered</small></div>
      <div class="cle-07__var"><a href="#c" class="cle-07__rainbow">Rainbow drift</a><small class="cle-07__cap">C · march + hue-rotate</small></div>
    </div>
  </div>
</section>
.cle-07,
.cle-07 *,
.cle-07 *::before,
.cle-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-07 {
  width: 100%;
  min-height: 100vh;
  --ink: oklch(0.3 0.04 340);
  --brand: oklch(0.62 0.19 20);
  font-family: Georgia,'Times New Roman',serif;
}

.cle-07__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 52px;
  border: 1px solid oklch(0.9 0.02 60);
  background: linear-gradient(170deg,oklch(0.98 0.012 75),oklch(0.95 0.02 60));
  padding: 44px 36px;
  color: var(--ink);
}

.cle-07__prose {
  max-width: 52ch;
  font-size: 19px;
  line-height: 1.75;
  text-wrap: pretty;
}

.cle-07__row {
  display: flex;
  flex-wrap: wrap;
  gap: 40px 64px;
  justify-content: center;
}

.cle-07__var {
  display: grid;
  gap: 13px;
  justify-items: center;
}

.cle-07__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.55 0.04 40);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-07 a {
  color: var(--ink);
  text-decoration: none;
  font-weight: 600;
}

.cle-07__row a {
  font-size: 22px;
}

.cle-07 a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 4px;
  border-radius: 2px;
}

.cle-07__native {
  text-decoration: underline;
  text-decoration-style: wavy;
  text-decoration-color: transparent;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  transition: text-decoration-color .35s,text-underline-offset .35s,color .3s;
}

.cle-07__native:hover,
.cle-07__native:focus-visible {
  text-decoration-color: var(--brand);
  text-underline-offset: 5px;
  color: oklch(0.45 0.16 20);
}

.cle-07__march {
  padding-bottom: 9px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 8'%3E%3Cpath d='M0 4 Q5 0 10 4 T20 4' fill='none' stroke='%23d64545' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") repeat-x 0 100% / 20px 0;
  animation: clh07-march .7s linear infinite;
  transition: background-size .3s;
}

.cle-07__march:hover,
.cle-07__march:focus-visible {
  background-size: 20px 7px;
}

.cle-07__rainbow {
  position: relative;
  padding-bottom: 9px;
}

.cle-07__rainbow::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 7px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 8'%3E%3Cpath d='M0 4 Q5 0 10 4 T20 4' fill='none' stroke='%23d64545' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") repeat-x 0 50% / 20px 7px;
  opacity: 0;
  translate: 0 -3px;
  transition: opacity .3s,translate .3s;
  animation: clh07-march .7s linear infinite,clh07-hue 2.4s linear infinite;
}

.cle-07__rainbow:hover::after,
.cle-07__rainbow:focus-visible::after {
  opacity: 1;
  translate: 0 0;
}

@keyframes clh07-march {
  to {
    background-position-x: 20px;
  }
}

@keyframes clh07-hue {
  to {
    filter: hue-rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .cle-07 *,
    .cle-07 *::after {
    animation: none !important;
    transition-duration: .01s !important;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Link Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Wavy / Squiggly Underline Hover Effect
Source: https://codefronts.com/motion/css-link-effects/squiggly-underline/

Squiggles, three ways: the one-line native route (text-decoration-style: wavy fading in — wraps across lines for free), a marching squiggle that scrolls under the link for as long as you hover, and a rainbow squiggle that drifts through hues while it marches. The SVG wave is a 200-byte data-URI, no assets.
## HTML
```html
<section class="cle-07" aria-label="Wavy underline hover demo">
  <div class="cle-07__stage">
    <p class="cle-07__prose">Field notes from the print room — <a href="#a" class="cle-07__native">the overprint experiments</a> ran long, but the riso drum forgave us. Next week we ink <a href="#a2" class="cle-07__native">a two-color zine</a>.</p>
    <div class="cle-07__row">
      <div class="cle-07__var"><a href="#b" class="cle-07__march">Marching squiggle</a><small class="cle-07__cap">B · scrolls while hovered</small></div>
      <div class="cle-07__var"><a href="#c" class="cle-07__rainbow">Rainbow drift</a><small class="cle-07__cap">C · march + hue-rotate</small></div>
    </div>
  </div>
</section>
```
## CSS
```css
.cle-07,
.cle-07 *,
.cle-07 *::before,
.cle-07 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-07 {
  width: 100%;
  min-height: 100vh;
  --ink: oklch(0.3 0.04 340);
  --brand: oklch(0.62 0.19 20);
  font-family: Georgia,'Times New Roman',serif;
}

.cle-07__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 52px;
  border: 1px solid oklch(0.9 0.02 60);
  background: linear-gradient(170deg,oklch(0.98 0.012 75),oklch(0.95 0.02 60));
  padding: 44px 36px;
  color: var(--ink);
}

.cle-07__prose {
  max-width: 52ch;
  font-size: 19px;
  line-height: 1.75;
  text-wrap: pretty;
}

.cle-07__row {
  display: flex;
  flex-wrap: wrap;
  gap: 40px 64px;
  justify-content: center;
}

.cle-07__var {
  display: grid;
  gap: 13px;
  justify-items: center;
}

.cle-07__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.55 0.04 40);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-07 a {
  color: var(--ink);
  text-decoration: none;
  font-weight: 600;
}

.cle-07__row a {
  font-size: 22px;
}

.cle-07 a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 4px;
  border-radius: 2px;
}

.cle-07__native {
  text-decoration: underline;
  text-decoration-style: wavy;
  text-decoration-color: transparent;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
  transition: text-decoration-color .35s,text-underline-offset .35s,color .3s;
}

.cle-07__native:hover,
.cle-07__native:focus-visible {
  text-decoration-color: var(--brand);
  text-underline-offset: 5px;
  color: oklch(0.45 0.16 20);
}

.cle-07__march {
  padding-bottom: 9px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 8'%3E%3Cpath d='M0 4 Q5 0 10 4 T20 4' fill='none' stroke='%23d64545' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") repeat-x 0 100% / 20px 0;
  animation: clh07-march .7s linear infinite;
  transition: background-size .3s;
}

.cle-07__march:hover,
.cle-07__march:focus-visible {
  background-size: 20px 7px;
}

.cle-07__rainbow {
  position: relative;
  padding-bottom: 9px;
}

.cle-07__rainbow::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 7px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 8'%3E%3Cpath d='M0 4 Q5 0 10 4 T20 4' fill='none' stroke='%23d64545' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E") repeat-x 0 50% / 20px 7px;
  opacity: 0;
  translate: 0 -3px;
  transition: opacity .3s,translate .3s;
  animation: clh07-march .7s linear infinite,clh07-hue 2.4s linear infinite;
}

.cle-07__rainbow:hover::after,
.cle-07__rainbow:focus-visible::after {
  opacity: 1;
  translate: 0 0;
}

@keyframes clh07-march {
  to {
    background-position-x: 20px;
  }
}

@keyframes clh07-hue {
  to {
    filter: hue-rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .cle-07 *,
    .cle-07 *::after {
    animation: none !important;
    transition-duration: .01s !important;
  }
}
```

How this works

A is the platform doing the work: the underline is always there, just transparent. Hover transitions text-decoration-color and nudges text-underline-offset, so the squiggle fades in and settles a pixel lower — and because it's a real text decoration, it follows wrapped lines perfectly.

B and C draw the squiggle as a repeating background tile:

.march{
  background: url("data:image/svg+xml,…wave…") repeat-x 0 100% / 20px 0;
  animation: clh07-march .7s linear infinite;  /* scrolls position-x */
  transition: background-size .3s;
}
.march:hover{ background-size: 20px 7px; }

The animation scrolls background-position-x one full 20px tile per loop — seamless forever. The reveal is separate: background-size's height goes 0 → 7px, so the wave surfaces smoothly while already in motion. C moves the same tile onto an ::after strip and adds a hue-rotate keyframe — filtering the pseudo-element, never the link text.

Make it yours

  • Wave shape: the data-URI path M0 4 Q5 0 10 4 T20 4 — deepen the Q/T control points for a rougher scribble.
  • March speed: the .7s loop (one tile per loop). Reverse it with a negative to position.
  • A's offset settle distance: the resting vs hover text-underline-offset pair.
  • Recolor B by editing stroke='%23…' in the URI — %23 is an URL-encoded #.

Gotchas — read before shipping

  • The background-tile squiggle (B) paints once per element box, not per line — on wrapped links it underlines only the last line's box. Multi-line prose should use variant A.
  • Keep the animation running (not toggled) and reveal via background-size; toggling the animation on hover makes the wave jump phase on re-entry.
  • text-decoration-style:wavy renders slightly different amplitudes per engine — don't pixel-match it to a drawn SVG next door.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

text-decoration-color/-style/-offset are Baseline (Safari 12.1+, Chrome 87+, Firefox 70+); animated underline-offset falls back to a snap where not interpolated. Background squiggles work everywhere; oklch sets the stated floor.

Search CodeFronts

Loading…