20 CSS Animated Buttons 20 / 20

CSS Ghost Button Background Reveal

Transparent ghost buttons that elegantly fill with a solid colour or gradient on hover, with JS-powered ripple origin tracking so the fill emanates from the exact cursor position.

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

The code

<div class="ab-20">
  <p class="ab-20__label">Hover from any direction</p>
  <div class="ab-20__row">
    <button class="ab-20__btn ab-20__btn--violet">
      <span class="ab-20__fill"></span>
      <span class="ab-20__label-inner">Get Started</span>
    </button>
    <button class="ab-20__btn ab-20__btn--cyan">
      <span class="ab-20__fill"></span>
      <span class="ab-20__label-inner">View Docs</span>
    </button>
    <button class="ab-20__btn ab-20__btn--rose">
      <span class="ab-20__fill"></span>
      <span class="ab-20__label-inner">Contact Us</span>
    </button>
  </div>
</div>
.ab-20,.ab-20 *,.ab-20 *::before,.ab-20 *::after{box-sizing:border-box;margin:0;padding:0}
.ab-20 ::selection{background:#7c3aed;color:#fff}
.ab-20{
  font-family:system-ui,sans-serif;
  background:#0a0a14;
  min-height:100vh;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:2rem;
  padding:2rem;
}
.ab-20__label{font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:#475569}
.ab-20__row{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center}
.ab-20__btn{
  --fill-color:#7c3aed;
  position:relative;
  overflow:hidden;
  padding:.85rem 2.2rem;
  font-size:.95rem;
  font-weight:700;
  color:#e2e8f0;
  background:transparent;
  border:2px solid rgba(255,255,255,.2);
  border-radius:10px;
  cursor:pointer;
  outline:none;
  transition:color .35s,border-color .35s;
}
.ab-20__btn--violet{--fill-color:#7c3aed;color:#c4b5fd;border-color:#7c3aed}
.ab-20__btn--cyan{--fill-color:#0891b2;color:#67e8f9;border-color:#0891b2}
.ab-20__btn--rose{--fill-color:#e11d48;color:#fda4af;border-color:#e11d48}
.ab-20__btn.is-hovered{color:#fff;border-color:transparent}
.ab-20__fill{
  position:absolute;
  border-radius:50%;
  background:var(--fill-color);
  left:var(--rx,50%);
  top:var(--ry,50%);
  width:0;height:0;
  transform:translate(-50%,-50%);
  transition:width .5s cubic-bezier(.4,0,.2,1),height .5s cubic-bezier(.4,0,.2,1);
  pointer-events:none;
  z-index:0;
}
.ab-20__btn.is-hovered .ab-20__fill{
  width:var(--size,400px);
  height:var(--size,400px);
}
.ab-20__label-inner{position:relative;z-index:1}
@media(prefers-reduced-motion:reduce){
  .ab-20__fill{transition:none}
  .ab-20__btn.is-hovered .ab-20__fill{width:100%;height:100%;border-radius:0}
}
(function(){
  var btns=document.querySelectorAll('.ab-20__btn');
  btns.forEach(function(btn){
    var fill=btn.querySelector('.ab-20__fill');
    if(!fill)return;
    var diag=Math.sqrt(Math.pow(btn.offsetWidth,2)+Math.pow(btn.offsetHeight,2));
    btn.style.setProperty('--size',diag*3+'px');
    btn.addEventListener('mouseenter',function(e){
      fill.style.setProperty('--rx',e.offsetX+'px');
      fill.style.setProperty('--ry',e.offsetY+'px');
      btn.classList.add('is-hovered');
    });
    btn.addEventListener('mouseleave',function(e){
      fill.style.setProperty('--rx',e.offsetX+'px');
      fill.style.setProperty('--ry',e.offsetY+'px');
      btn.classList.remove('is-hovered');
    });
  });
})();

How this works

In the resting state each button has a transparent background and a visible border — the classic ghost button look. The fill lives in an absolutely-positioned span.ab-20__fill inside each button. This span starts as a tiny circle (width: 0; height: 0; border-radius: 50%) at the cursor's entry position, computed in JS from event.offsetX/Y and set via CSS custom properties --rx/--ry. On hover, a CSS transition scales the fill circle to width: var(--size); height: var(--size) where --size is set to 300% of the button's diagonal length, large enough to cover the entire button area regardless of entry point.

On mouse-leave, JS reads the exit position and sets new --rx/--ry coordinates before removing the .is-hovered class, making the fill collapse back toward the exit point rather than an arbitrary corner. This bidirectional, cursor-aware animation is impossible with CSS-only :hover and is the key reason this demo uses JS. All rendering is done via CSS transitions — JS only sets coordinates and toggles the class.

Customize

  • Change the fill colour per button by setting a data-fill attribute and reading it in JS to apply as a background on the fill span.
  • Use a gradient fill by setting the fill span's background to linear-gradient(135deg, #7c3aed, #2563eb) for a premium reveal effect.
  • Adjust the expand speed by changing the transition-duration on .ab-20__fill from .5s to .3s for a snappier expand.
  • Cap the fill radius for a partial-reveal aesthetic by reducing --size in JS to 150% of the diagonal instead of 300%.
  • Combine with the border-draw technique (demo 04) by removing the button border initially and only showing it after the fill has contracted back out.

Watch out for

  • The fill span must have pointer-events: none to prevent it from capturing the mouseleave event as the cursor crosses onto the growing fill layer — without this the exit animation fires prematurely.
  • Setting overflow: hidden on the button is essential to clip the expanding circle — also ensure the button has position: relative so the fill span's absolute positioning is correctly anchored.
  • The cursor-origin approach uses event.offsetX/Y which is relative to the element's padding edge — if the button has significant padding, the origin will appear slightly inset from the true cursor position; use getBoundingClientRect() for pixel-perfect accuracy.

Browser support

ChromeSafariFirefoxEdge
49+ 10.1+ 44+ 49+

Uses standard DOM events and CSS transitions; supported in all modern browsers.

Search CodeFronts

Loading…