16 CSS Gradient Animations 08 / 16

CSS Neon Flowing Underline Link

Navigation links and inline prose anchors where hover activates a flowing multi-colour gradient underline that sweeps in from the left and then animates infinitely, replacing the static underline with a living neon ribbon.

CSS + JS MIT licensed
Live Demo Open in tab
Open in playground

The code

<div class="ga-08">

  <nav class="ga-08__nav">
    <span class="ga-08__link ga-08__link--active">Home</span>
    <span class="ga-08__link">Features</span>
    <span class="ga-08__link">Pricing</span>
    <span class="ga-08__link">Blog</span>
    <span class="ga-08__link">Contact</span>
  </nav>

  <div class="ga-08__prose">
    <h2 class="ga-08__prose-head">Links that feel alive</h2>
    <p class="ga-08__prose-body">
      Hover the navigation above to see each link reveal its own unique
      <span class="ga-08__inline">flowing gradient underline</span>. In body copy,
      the same technique works at a smaller scale — try hovering
      <span class="ga-08__inline">this inline link</span> or
      <span class="ga-08__inline" style="--ul-c1:#a855f7;--ul-c2:#f97316;--ul-c3:#eab308;">this one</span>
      to see the neon line slide in from the left and animate infinitely.
    </p>
  </div>

  <div class="ga-08__speed">
    <span class="ga-08__speed-label">Flow speed:</span>
    <button class="ga-08__speed-btn" data-dur="4s">Slow</button>
    <button class="ga-08__speed-btn active" data-dur="2s">Normal</button>
    <button class="ga-08__speed-btn" data-dur="1s">Fast</button>
  </div>

</div>
.ga-08, .ga-08 *, .ga-08 *::before, .ga-08 *::after {
  margin: 0; padding: 0; box-sizing: border-box;
}
.ga-08 ::selection { background: rgba(6,182,212,.4); color: #fff; }

.ga-08 {
  --bg: #080c12;
  --dur: 2s;
  width: 100%;
  min-height: 100vh;
  background: var(--bg);
  font-family: system-ui, -apple-system, sans-serif;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0;
  padding: 48px 24px;
}

/* ── Realistic nav bar ── */
.ga-08__nav {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 12px 24px;
  background: rgba(255,255,255,.03);
  border: 1px solid rgba(255,255,255,.06);
  border-radius: 14px;
  margin-bottom: 56px;
}

/* ── Core underline link technique ── */
.ga-08__link {
  position: relative;
  display: inline-block;
  padding: 6px 14px;
  font-size: .9rem;
  font-weight: 500;
  color: rgba(255,255,255,.45);
  text-decoration: none;
  cursor: pointer;
  transition: color .3s ease;
  border-radius: 8px;
  white-space: nowrap;
}

/* Underline via ::after using gradient background-size trick */
.ga-08__link::after {
  content: '';
  position: absolute;
  bottom: 2px;
  left: 14px;
  right: 14px;
  height: 2px;
  border-radius: 999px;
  background: linear-gradient(
    90deg,
    var(--ul-c1, #06b6d4),
    var(--ul-c2, #818cf8),
    var(--ul-c3, #ec4899),
    var(--ul-c1, #06b6d4)
  );
  background-size: 200% 100%;
  background-position: 100% 0;
  transform: scaleX(0);
  transform-origin: right;
  transition:
    transform .35s cubic-bezier(.22,1,.36,1),
    background-position var(--dur) linear;
}

.ga-08__link:hover {
  color: rgba(255,255,255,.92);
}
.ga-08__link:hover::after {
  transform: scaleX(1);
  transform-origin: left;
  background-position: -100% 0;
  animation: ga-08-flow var(--dur) linear infinite;
}

@keyframes ga-08-flow {
  0%   { background-position: 100% 0; }
  100% { background-position: -100% 0; }
}

/* Active nav item — always underlined */
.ga-08__link--active {
  color: rgba(255,255,255,.9);
}
.ga-08__link--active::after {
  transform: scaleX(1);
  animation: ga-08-flow var(--dur) linear infinite;
}

/* Custom colour variants per link */
.ga-08__link:nth-child(2)  { --ul-c1: #a855f7; --ul-c2: #ec4899; --ul-c3: #f97316; }
.ga-08__link:nth-child(3)  { --ul-c1: #10b981; --ul-c2: #06b6d4; --ul-c3: #6366f1; }
.ga-08__link:nth-child(4)  { --ul-c1: #f97316; --ul-c2: #eab308; --ul-c3: #ef4444; }
.ga-08__link:nth-child(5)  { --ul-c1: #ec4899; --ul-c2: #a855f7; --ul-c3: #6366f1; }

/* ── Prose section showing links in copy ── */
.ga-08__prose {
  max-width: 560px;
  text-align: center;
}
.ga-08__prose-head {
  font-size: clamp(1.6rem, 3.5vw, 2.2rem);
  font-weight: 800;
  color: #f1f5f9;
  letter-spacing: -.025em;
  margin-bottom: 20px;
  line-height: 1.2;
}
.ga-08__prose-body {
  font-size: .95rem;
  color: rgba(255,255,255,.45);
  line-height: 1.8;
}
/* Inline prose links */
.ga-08__inline {
  position: relative;
  display: inline;
  color: rgba(255,255,255,.8);
  text-decoration: none;
  cursor: pointer;
  padding-bottom: 2px;
}
.ga-08__inline::after {
  content: '';
  position: absolute;
  left: 0; right: 0;
  bottom: -1px;
  height: 1.5px;
  border-radius: 999px;
  background: linear-gradient(90deg, #06b6d4, #818cf8, #ec4899, #06b6d4);
  background-size: 300% 100%;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform .3s cubic-bezier(.22,1,.36,1);
}
.ga-08__inline:hover { color: #fff; }
.ga-08__inline:hover::after {
  transform: scaleX(1);
  transform-origin: left;
  animation: ga-08-flow-inline 1.8s linear infinite;
}
@keyframes ga-08-flow-inline {
  0%   { background-position: 100% 0; }
  100% { background-position: -100% 0; }
}

/* Speed control */
.ga-08__speed {
  margin-top: 40px;
  display: flex;
  align-items: center;
  gap: 8px;
}
.ga-08__speed-label {
  font-size: .7rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: rgba(255,255,255,.25);
}
.ga-08__speed-btn {
  padding: 4px 12px;
  font-size: .72rem;
  font-weight: 700;
  border-radius: 6px;
  border: 1px solid rgba(255,255,255,.1);
  background: transparent;
  color: rgba(255,255,255,.35);
  cursor: pointer;
  transition: all .2s;
}
.ga-08__speed-btn.active,
.ga-08__speed-btn:hover {
  background: rgba(6,182,212,.15);
  border-color: rgba(6,182,212,.35);
  color: #67e8f9;
}

@media (prefers-reduced-motion: reduce) {
  .ga-08__link:hover::after,
  .ga-08__link--active::after,
  .ga-08__inline:hover::after { animation: none; }
  .ga-08__link::after,
  .ga-08__inline::after { transition: transform .3s ease; }
}
(function() {
  const w = document.querySelector('.ga-08');
  w.querySelectorAll('.ga-08__speed-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      w.querySelectorAll('.ga-08__speed-btn').forEach(b => b.classList.remove('active'));
      btn.classList.add('active');
      w.style.setProperty('--dur', btn.dataset.dur);
    });
  });
})();

How this works

The underline is a 2px-tall ::after pseudo-element positioned at bottom: 2px with its width spanning the link text. At rest, transform: scaleX(0) hides it completely with no layout cost. On hover, transform-origin is switched from right to left and transform: scaleX(1) triggers a cubic-bezier(.22,1,.36,1) spring-style expansion, making the line sweep in from the left edge. Once fully expanded, the @keyframes ga-08-flow animation takes over, cycling background-position from 100% to -100% on a gradient with background-size: 200% 100% — this repeats the colour palette end-to-end, creating the continuous neon flow.

Each navigation link carries its own --ul-c1, --ul-c2, --ul-c3 custom properties to give every link its own colour signature. The flow speed is exposed as --dur on the root wrapper and piped into the keyframe animation via CSS custom property inheritance, so a single property swap from JS updates all underlines simultaneously.

Customize

  • Give each link its own colour signature by setting --ul-c1, --ul-c2, and --ul-c3 on individual .ga-08__link elements — the gradient stops pick them up automatically.
  • Make the underline thicker for prominent links by editing height: 2px on .ga-08__link::after — try 3px for a bolder neon bar or 1px for an ultra-fine hairline.
  • Change the sweep-in easing from cubic-bezier(.22,1,.36,1) to cubic-bezier(.34,1.56,.64,1) on transform for a springy overshoot that gives the underline a more playful entrance.
  • Extend the gradient to four stops by adding a fourth colour to the linear-gradient and increasing background-size from 200% to 300% for a richer colour journey.
  • Add an always-active underline to a specific link (e.g. the current page) by applying .ga-08__link--active which starts the flow animation immediately without requiring hover.

Watch out for

  • Switching transform-origin from right to left on hover while simultaneously transitioning transform: scaleX() can cause a flicker in Firefox if both changes happen in the same frame — the current implementation staggers them via the initial right origin at rest, which avoids the issue.
  • Using display: inline on links means the ::after pseudo-element must also be treated as inline-level; switching display: inline-block on the link itself is required for position: relative and padding-bottom to work correctly on wrapping text.
  • The @keyframes ga-08-flow animation starts mid-hover — if the user hovers and quickly un-hovers, the animation may be mid-cycle when the scaleX(0) collapse happens, causing a brief visible tail. Delay the animation start by 50ms using animation-delay to let the expand complete first.

Browser support

ChromeSafariFirefoxEdge
49+ 9.1+ 36+ 49+

All techniques are universally supported. The cubic-bezier easing and background-position animation have had broad support since 2015+.

Search CodeFronts

Loading…