26 CSS Link Effects16 / 26

CSS + JSMIT licensed

CSS Staggered Letter Push Hover Effect

Per-letter choreography with a 14-line accessible splitter: hover a link and its letters roll over one by one, each replaced from below by an accent copy — the effect every award-site nav uses. Plus a gentler cascade lift and a jelly wave that undulates while hovered. The stagger is one line of math: transition-delay: calc(var(--i) * 28ms).

Published Updated

Live Demo
Try it

The code

<section class="cle-16" aria-label="Staggered letter hover demo">
  <div class="cle-16__stage">
    <nav class="cle-16__nav" aria-label="Studio">
      <div class="cle-16__var"><a href="#a" class="cle-16__roll" data-split="roll">STUDIO</a><small class="cle-16__cap">A · letter roll-over</small></div>
      <div class="cle-16__var"><a href="#b" class="cle-16__lift" data-split="lift">PROJECTS</a><small class="cle-16__cap">B · cascade lift</small></div>
      <div class="cle-16__var"><a href="#c" class="cle-16__wavey" data-split="wave">CONTACT</a><small class="cle-16__cap">C · jelly wave loop</small></div>
    </nav>
  </div>
</section>
.cle-16,
.cle-16 *,
.cle-16 *::before,
.cle-16 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-16 {
  width: 100%;
  min-height: 100vh;
  --ink: oklch(0.92 0.01 100);
  --accent: oklch(0.85 0.17 100);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-16__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  place-items: center;
  background: linear-gradient(170deg,oklch(0.24 0.015 100),oklch(0.17 0.012 90));
  padding: 44px 32px;
}

.cle-16__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 44px 64px;
  justify-content: center;
}

.cle-16__var {
  display: grid;
  gap: 14px;
  justify-items: center;
}

.cle-16__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.58 0.04 95);
}

.cle-16 a {
  color: var(--ink);
  text-decoration: none;
  font-size: clamp(24px,3.6vw,34px);
  font-weight: 800;
  letter-spacing: .06em;
  white-space: nowrap;
}

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

.cle-16__w {
  display: inline-block;
}

.cle-16__ch {
  position: relative;
  display: inline-block;
  transition: translate .4s cubic-bezier(.3,1,.3,1),color .3s;
  transition-delay: calc(var(--i) * 28ms);
}

.cle-16__roll {
  display: inline-block;
  overflow: hidden;
}

.cle-16__roll .cle-16__ch::after {
  content: attr(data-char);
  position: absolute;
  left: 0;
  top: 100%;
  color: var(--accent);
}

.cle-16__roll:hover .cle-16__ch,
.cle-16__roll:focus-visible .cle-16__ch {
  translate: 0 -100%;
}

.cle-16__lift:hover .cle-16__ch,
.cle-16__lift:focus-visible .cle-16__ch {
  translate: 0 -5px;
  color: var(--accent);
}

.cle-16__wavey:hover .cle-16__ch,
.cle-16__wavey:focus-visible .cle-16__ch {
  animation: clh16-wave .9s ease-in-out infinite;
  animation-delay: calc(var(--i) * 60ms);
  color: var(--accent);
}

@keyframes clh16-wave {
  0%,
    100% {
    translate: 0 0;
  }

  50% {
    translate: 0 -7px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cle-16 .cle-16__ch {
    transition: color .3s;
    transition-delay: 0s;
    animation: none !important;
  }

  .cle-16 a:hover .cle-16__ch {
    translate: 0 0;
    color: var(--accent);
  }
}
(() => {
  document.querySelectorAll('.cle-16 a[data-split]').forEach((a) => {
    const label = a.textContent.trim();
    a.setAttribute('aria-label', label);
    const wrap = document.createElement('span');
    wrap.className = 'cle-16__w';
    wrap.setAttribute('aria-hidden', 'true');
    [...label].forEach((ch, i) => {
      const s = document.createElement('span');
      s.className = 'cle-16__ch';
      s.style.setProperty('--i', i);
      s.dataset.char = ch;
      s.innerHTML = ch === ' ' ? '&nbsp;' : ch;
      wrap.append(s);
    });
    a.replaceChildren(wrap);
  });
})();
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 Staggered Letter Push Hover Effect
Source: https://codefronts.com/motion/css-link-effects/letter-push/

Per-letter choreography with a 14-line accessible splitter: hover a link and its letters roll over one by one, each replaced from below by an accent copy — the effect every award-site nav uses. Plus a gentler cascade lift and a jelly wave that undulates while hovered. The stagger is one line of math: transition-delay: calc(var(--i) * 28ms).
## HTML
```html
<section class="cle-16" aria-label="Staggered letter hover demo">
  <div class="cle-16__stage">
    <nav class="cle-16__nav" aria-label="Studio">
      <div class="cle-16__var"><a href="#a" class="cle-16__roll" data-split="roll">STUDIO</a><small class="cle-16__cap">A · letter roll-over</small></div>
      <div class="cle-16__var"><a href="#b" class="cle-16__lift" data-split="lift">PROJECTS</a><small class="cle-16__cap">B · cascade lift</small></div>
      <div class="cle-16__var"><a href="#c" class="cle-16__wavey" data-split="wave">CONTACT</a><small class="cle-16__cap">C · jelly wave loop</small></div>
    </nav>
  </div>
</section>
```
## CSS
```css
.cle-16,
.cle-16 *,
.cle-16 *::before,
.cle-16 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cle-16 {
  width: 100%;
  min-height: 100vh;
  --ink: oklch(0.92 0.01 100);
  --accent: oklch(0.85 0.17 100);
  font-family: 'Segoe UI',system-ui,sans-serif;
}

.cle-16__stage {
  width: 100%;
  height: 100vh;
  display: grid;
  place-items: center;
  background: linear-gradient(170deg,oklch(0.24 0.015 100),oklch(0.17 0.012 90));
  padding: 44px 32px;
}

.cle-16__nav {
  display: flex;
  flex-wrap: wrap;
  gap: 44px 64px;
  justify-content: center;
}

.cle-16__var {
  display: grid;
  gap: 14px;
  justify-items: center;
}

.cle-16__cap {
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: oklch(0.58 0.04 95);
}

.cle-16 a {
  color: var(--ink);
  text-decoration: none;
  font-size: clamp(24px,3.6vw,34px);
  font-weight: 800;
  letter-spacing: .06em;
  white-space: nowrap;
}

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

.cle-16__w {
  display: inline-block;
}

.cle-16__ch {
  position: relative;
  display: inline-block;
  transition: translate .4s cubic-bezier(.3,1,.3,1),color .3s;
  transition-delay: calc(var(--i) * 28ms);
}

.cle-16__roll {
  display: inline-block;
  overflow: hidden;
}

.cle-16__roll .cle-16__ch::after {
  content: attr(data-char);
  position: absolute;
  left: 0;
  top: 100%;
  color: var(--accent);
}

.cle-16__roll:hover .cle-16__ch,
.cle-16__roll:focus-visible .cle-16__ch {
  translate: 0 -100%;
}

.cle-16__lift:hover .cle-16__ch,
.cle-16__lift:focus-visible .cle-16__ch {
  translate: 0 -5px;
  color: var(--accent);
}

.cle-16__wavey:hover .cle-16__ch,
.cle-16__wavey:focus-visible .cle-16__ch {
  animation: clh16-wave .9s ease-in-out infinite;
  animation-delay: calc(var(--i) * 60ms);
  color: var(--accent);
}

@keyframes clh16-wave {
  0%,
    100% {
    translate: 0 0;
  }

  50% {
    translate: 0 -7px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cle-16 .cle-16__ch {
    transition: color .3s;
    transition-delay: 0s;
    animation: none !important;
  }

  .cle-16 a:hover .cle-16__ch {
    translate: 0 0;
    color: var(--accent);
  }
}
```

## JavaScript
```js
(() => {
  document.querySelectorAll('.cle-16 a[data-split]').forEach((a) => {
    const label = a.textContent.trim();
    a.setAttribute('aria-label', label);
    const wrap = document.createElement('span');
    wrap.className = 'cle-16__w';
    wrap.setAttribute('aria-hidden', 'true');
    [...label].forEach((ch, i) => {
      const s = document.createElement('span');
      s.className = 'cle-16__ch';
      s.style.setProperty('--i', i);
      s.dataset.char = ch;
      s.innerHTML = ch === ' ' ? '&nbsp;' : ch;
      wrap.append(s);
    });
    a.replaceChildren(wrap);
  });
})();
```

How this works

CSS can't select individual characters, so a tiny script wraps each one in a span carrying its index — and keeps the link accessible while doing it:

a.setAttribute('aria-label', label);      // real name preserved
wrap.setAttribute('aria-hidden', 'true'); // spans are presentation only
span.style.setProperty('--i', i);          // the stagger key

Every motion rule then keys off --i: transition-delay: calc(var(--i) * 28ms). That's the entire stagger engine — no timeline library.

The roll (A) clips the link's line box (overflow:hidden) and gives each letter a duplicate pinned below itself via ::after{ content: attr(data-char); top:100% }. Hovering translates every letter up by 100%, so the accent duplicate rides into the vacated space, left to right. B is the same stagger on a 4px lift + color change. C runs an infinite sine-bob keyframe per letter with animation-delay doing the phase offset — a standing wave through the word.

Make it yours

  • Stagger tempo: the 28ms multiplier; right-to-left cascade by feeding reversed indexes in the JS.
  • Roll distance is font-relative (100% translate), so it scales with any font-size for free.
  • C's wave: amplitude is the -6px keyframe, wavelength is the 60ms delay step.
  • The splitter is generic: any a[data-split] in scope gets processed; modes are just class hooks.

Gotchas — read before shipping

  • Split text is a screen-reader minefield: without aria-label + aria-hidden spans, VoiceOver reads 'S, t, u, d, i, o' letter by letter. The splitter handles both — keep it that way.
  • Spaces must become &nbsp; inside spans or they collapse and the word snaps shut.
  • Whole-word wrapping breaks (each letter is an inline-block): apply white-space:nowrap, and skip the effect on multi-word body links.

Browser support

ChromeSafariFirefoxEdge
111+16.2+113+111+

Transitions, custom properties and attr()-content are universal; the stated floor is just oklch colors. The splitter is 14 lines of ES6, no dependencies; links degrade to plain styled text without JS.

Search CodeFronts

Loading…