Copied!
Band

CSS button hover set

Six pure-CSS button hover animations: fill sweep, border draw, shine, split text, ripple and arrow slide.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "CSS button hover set" for my site using the Enrichr Library element at https://enrichr.dev/elements/css-button-hover-set.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/css-button-hover-set.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- speed: 450
- radius: 0.7
- size: 1

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/css-button-hover-set.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-cbh">
  <div class="ef-cbh__wrap"><div class="ef-cbh__grid">
    <button type="button" class="ef-cbh__b ef-cbh__b--fill"><span>Fill sweep</span></button>
    <button type="button" class="ef-cbh__b ef-cbh__b--draw"><span>Border draw</span></button>
    <button type="button" class="ef-cbh__b ef-cbh__b--shine"><span>Shine</span></button>
    <button type="button" class="ef-cbh__b ef-cbh__b--split" data-t="Split text"><span>Split text</span></button>
    <button type="button" class="ef-cbh__b ef-cbh__b--ripple"><span>Ripple</span></button>
    <button type="button" class="ef-cbh__b ef-cbh__b--arrow"><span>Arrow slide</span><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
  </div></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-cbh { width: 100%; }
.ef-cbh__wrap { --ef-acc: #e6ff55; --ef-dur: 450ms; --ef-rad: 0.7rem; --ef-sz: 1rem; box-sizing: border-box; width: 100%; min-height: 18rem; padding: 1.5rem; border-radius: 1rem; display: flex; align-items: center; justify-content: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-cbh__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1.2rem; width: 100%; max-width: 34rem; }
.ef-cbh__b { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.95em 1.2em; border: 1.5px solid var(--ef-acc); border-radius: var(--ef-rad); background: transparent; color: var(--ef-acc); font: inherit; font-size: var(--ef-sz); font-weight: 700; letter-spacing: 0.02em; cursor: pointer; overflow: hidden; outline: none; transition: color var(--ef-dur) cubic-bezier(.2,.8,.2,1), box-shadow 200ms; }
.ef-cbh__b > span { position: relative; z-index: 1; }
.ef-cbh__b:focus-visible { box-shadow: 0 0 0 3px #0c1e18, 0 0 0 5px var(--ef-acc); }
/* 1 fill sweep */
.ef-cbh__b--fill::before { content: ""; position: absolute; inset: 0; background: var(--ef-acc); transform: translateX(-101%); transition: transform var(--ef-dur) cubic-bezier(.2,.8,.2,1); }
.ef-cbh__b--fill:hover::before, .ef-cbh__b--fill:focus-visible::before { transform: none; }
.ef-cbh__b--fill:hover, .ef-cbh__b--fill:focus-visible { color: #0c1e18; }
/* 2 border draw */
.ef-cbh__b--draw { border-color: transparent; background: linear-gradient(var(--ef-acc), var(--ef-acc)) top left / 0 2px no-repeat, linear-gradient(var(--ef-acc), var(--ef-acc)) top right / 2px 0 no-repeat, linear-gradient(var(--ef-acc), var(--ef-acc)) bottom right / 0 2px no-repeat, linear-gradient(var(--ef-acc), var(--ef-acc)) bottom left / 2px 0 no-repeat; box-shadow: inset 0 0 0 1.5px rgba(230, 255, 85, 0.28); transition: background-size calc(var(--ef-dur) / 2) linear, color var(--ef-dur); }
.ef-cbh__b--draw:hover, .ef-cbh__b--draw:focus-visible { background-size: 100% 2px, 2px 100%, 100% 2px, 2px 100%; transition-delay: 0s, calc(var(--ef-dur) / 2), 0s, calc(var(--ef-dur) / 2); }
.ef-cbh__b--draw:focus-visible { box-shadow: inset 0 0 0 1.5px rgba(230, 255, 85, 0.28), 0 0 0 5px rgba(230, 255, 85, 0.5); }
/* 3 shine */
.ef-cbh__b--shine { background: var(--ef-acc); color: #0c1e18; }
.ef-cbh__b--shine::after { content: ""; position: absolute; top: 0; bottom: 0; left: -60%; width: 40%; background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.85), transparent); transform: skewX(-20deg); }
.ef-cbh__b--shine:hover::after, .ef-cbh__b--shine:focus-visible::after { left: 130%; transition: left calc(var(--ef-dur) * 1.4) ease; }
/* 4 split text */
.ef-cbh__b--split > span { display: block; transition: transform var(--ef-dur) cubic-bezier(.2,.8,.2,1); }
.ef-cbh__b--split::after { content: attr(data-t); position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--ef-acc); color: #0c1e18; clip-path: inset(0 0 100% 0); transition: clip-path var(--ef-dur) cubic-bezier(.2,.8,.2,1); z-index: 2; }
.ef-cbh__b--split:hover::after, .ef-cbh__b--split:focus-visible::after { clip-path: inset(0); }
.ef-cbh__b--split:hover > span, .ef-cbh__b--split:focus-visible > span { transform: translateY(-40%); }
/* 5 ripple-ish */
.ef-cbh__b--ripple::before { content: ""; position: absolute; left: 50%; top: 50%; width: 220%; aspect-ratio: 1; border-radius: 50%; background: var(--ef-acc); transform: translate(-50%, -50%) scale(0); transition: transform calc(var(--ef-dur) * 1.3) cubic-bezier(.2,.8,.2,1); }
.ef-cbh__b--ripple:hover::before, .ef-cbh__b--ripple:focus-visible::before { transform: translate(-50%, -50%) scale(1); }
.ef-cbh__b--ripple:hover, .ef-cbh__b--ripple:focus-visible { color: #0c1e18; }
.ef-cbh__b--ripple:active::before { transform: translate(-50%, -50%) scale(1.08); }
/* 6 arrow slide */
.ef-cbh__b--arrow svg { position: relative; z-index: 1; flex: none; width: 0; margin-left: -0.5rem; opacity: 0; transform: translateX(-8px); transition: width var(--ef-dur) cubic-bezier(.2,.8,.2,1), margin var(--ef-dur) cubic-bezier(.2,.8,.2,1), opacity var(--ef-dur), transform var(--ef-dur) cubic-bezier(.2,.8,.2,1); }
.ef-cbh__b--arrow:hover svg, .ef-cbh__b--arrow:focus-visible svg { width: 1.1em; margin-left: 0; opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .ef-cbh__b, .ef-cbh__b *, .ef-cbh__b::before, .ef-cbh__b::after { transition-duration: 1ms !important; transition-delay: 0s !important; }
  .ef-cbh__b--split:hover > span, .ef-cbh__b--split:focus-visible > span { transform: none; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// CSS button hover set: six pure-CSS hover effects (fill sweep, border draw, shine, split text, ripple, arrow slide).
// The effects live entirely in the stylesheet and also fire on keyboard focus. This script only writes four CSS variables
// on the wrapper (--ef-acc, --ef-dur, --ef-rad, --ef-sz), so there are no listeners or timers to clean up.
// mount(root, { background, accent, speed (ms), radius (rem), size (rem) })
function mount(root, o) {
  var wrap = root.querySelector(".ef-cbh__wrap");
  var opts = {};
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", speed: 450, radius: 0.7, size: 1 }, opts, next || {});
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--ef-dur", opts.speed + "ms");
    wrap.style.setProperty("--ef-rad", opts.radius + "rem");
    wrap.style.setProperty("--ef-sz", opts.size + "rem");
  }
  function destroy() { wrap.style.cssText = ""; }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":450,"radius":0.7,"size":1});

How it works

Each button is a normal element with a modifier class, and every effect is a transition on a pseudo-element or background. Fill sweep slides a ::before layer in, border draw grows four background-size strips in sequence, shine moves a skewed gradient across, split text reveals a duplicate label with clip-path using a data attribute, ripple scales a large circle from the centre, and arrow slide widens an inline SVG from zero. The script only writes the accent, duration, radius and size as CSS variables.

Accessibility and performance

All six are native buttons and every effect also triggers on :focus-visible, with a clear focus ring, so keyboard users see the same state. Hover states keep text legible by switching to dark ink when the fill arrives. With prefers-reduced-motion the transitions collapse to an instant state change.

No JavaScript runs after mount. Effects animate transform, clip-path and background-size on small elements, which is cheap. Avoid applying the shine or ripple layers to very large surfaces.

SectionBand
TechniquesPointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byIdeas for CSS Button Hover Animations by Manoela Ilic on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Is any JavaScript needed for the hover effects?

No. The script only sets four CSS variables; the animations are plain CSS and can be copied without it.

How do I change the timing per button?

Override --ef-dur on a single button. All the transitions read it.

Why does split text use a data attribute?

The duplicate label is drawn by ::after with content: attr(data-t), so it needs the same text without extra markup.