GODRICH

9 button hover effects css that award sites reuse

These button hover effects css techniques are the reactions a button gives when a pointer arrives, sorted by what each signals.

Auto-plays · click a tile to jump to its section · all nine in one zip

Look closely at award-winning sites and their button work stops looking random: the same handful of techniques keep showing up, each doing the same job. So the order below is not by popularity — it is the order in which a button meets a person. First it has to be noticed from across the page (01, 02), then it answers an approaching pointer (03), then it changes its surface, letters, or icon while you aim at it (04, 05, 06), then it returns a physical answer on press (07, 08), and finally it reports what is happening while you wait (09). No brand or site is copied here, only the technique, so every one of the nine takes your own colors and your own label.

01Cursor spotlight

A round pool of light tracks the pointer across the face of the button. Only the horizontal center moves, passed in as --mx, and the gradient paints everything else. Registering that variable is the part people miss.

radial-gradient@propertypointermove
@property --mx {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}
.spot::before {
  background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
@keyframes spot-sweep {
  0%   { --mx: 8%; }
  50%  { --mx: 92%; }
  100% { --mx: 8%; }
}

02Border light runner

A single bright point runs around the border, lap after lap, with no visible seam. The whole conic gradient rotates while a mask keeps nothing but the border's thickness, so the same rule follows a pill, a rectangle, or a squircle without any new geometry.

conic-gradientmask-composite@property --angle
.glow::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: $sp-1;
  background: conic-gradient(from var(--angle),
    rgba($color, 0) 0deg,
    rgba($color, 0) 232deg,
    rgba($color, 1) 300deg,
    rgba($color, .2) 330deg,
    rgba($color, 0) 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: glow-run $duration $easing infinite;
}

03Magnetic pull with label offset

The button travels toward the pointer while the label takes the same coordinate multiplied by a negative number. That one change of sign splits the two layers apart, and a flat rectangle suddenly reads as something floating slightly above the page.

translate역시차pointerleave
field.addEventListener('pointermove', function (e) {
  var r = btn.getBoundingClientRect();
  var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
  var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
  btn.classList.remove('is-demo');
  btn.style.translate = dx + 'px ' + dy + 'px';
  label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});

04Liquid fill with a wave line

Color rises from the bottom, but the surface tilts like water instead of staying flat. It is not a circle: a 260px square rounded to 42% turns on its own axis while the whole layer climbs, so the waterline is never level twice.

물결 타일translateoverflow
.liq__wave {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 260px;
  height: 260px;
  border-radius: 42%;
  background: $color;
  transform: translate(-50%, 100%);
}
@keyframes liq-rise {
  0%   { transform: translate(-50%, 100%) rotate(0deg); }
  52%  { transform: translate(-50%, 68%) rotate(180deg); }
  66%  { transform: translate(-50%, 68%) rotate(230deg); }
  100% { transform: translate(-50%, 100%) rotate(360deg); }
}

05Per-letter split slide

Letters leave upward one at a time and identical copies rise from below to fill the gap. Each letter gets its own overflow: hidden window holding two stacked copies, and a delay proportional to the letter index is what turns a row of separate moves into one ripple.

글자별 지연translate: 0 -50%clip
.split__ch {
  display: inline-block;
  overflow: hidden;
  height: 1.25em;
  line-height: 1.25;
}
.split.is-demo .split__ch b {
  animation: split-roll $duration $easing infinite;
  animation-delay: calc(var(--i) * #{$stagger});
}
@keyframes split-roll {
  0%,  12% { translate: 0 0; }
  42%, 68% { translate: 0 -50%; }
  100%     { translate: 0 0; }
}

06Icon swap on a spin

The icon turns half a revolution on its vertical axis and comes back as a different icon. Two icons share one cell, each hiding its own back face, so a button can go from add to added without touching its label.

rotateYbackface-visibilityPhosphor
.swap__ic {
  position: relative;
  width: 34px;
  height: 34px;
  transform-style: preserve-3d;
}
.swap__face { position: absolute; inset: 0; display: flex; backface-visibility: hidden; }
.swap__face--back { transform: rotateY(180deg); }
@keyframes swap-spin {
  0%,  22%  { transform: rotateY(0deg); }
  50%, 72%  { transform: rotateY(180deg); }
  100%      { transform: rotateY(360deg); }
}

07Squash and stretch press

A press flattens the button vertically and spreads it sideways; the release springs past the resting size and settles after one smaller wobble. Thinning the shadow on the same beat is the other half of the trick — scale alone looks like a glitch, while scale plus shadow looks like rubber.

scale$ease-popbox-shadow
.squash { transform-origin: center bottom; }
@keyframes squash-press {
  0%,  34%  { transform: scale(1, 1);      box-shadow: $shadow-raised; }
  46%       { transform: scale(1.06, .88); box-shadow: $shadow-press; }
  60%       { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
  72%       { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
  86%, 100% { transform: scale(1, 1);      box-shadow: $shadow-raised; }
}
.squash:active {
  animation: none;
  transform: scale(1.06, .88);
  box-shadow: $shadow-press;
}

08Corner brackets closing in

Four L-shaped pieces close in from the outside and complete a frame. Each corner differs only in which two borders it drops and which way it travels; the distance stays 18px for all four, so widening or tightening the whole frame is a single number.

translatefocus-visible순차 지연
.brk__c {
  position: absolute;
  width: 22px;
  height: 22px;
  border: 3px solid $color;
  --tx: -18px;
  --ty: -18px;
}
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk.is-demo .brk__c {
  animation: brk-close $duration $easing infinite;
  animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
  0%       { translate: var(--tx) var(--ty); opacity: 0; }
  38%, 66% { translate: 0 0;                 opacity: 1; }
  100%     { translate: var(--tx) var(--ty); opacity: 0; }
}

09Loading morph: button to spinner to check

A press hides the label, narrows the pill into a circle, spins, draws a check, and then returns to full width. Three states live inside one element, so no separate loading overlay is needed, and a second press cannot get through while the first is still running.

widthstroke-dashoffsetaria-busy
@keyframes morph-box {
  0%,  10%  { width: 200px; }
  28%, 62%  { width: 56px; }
  84%, 100% { width: 200px; }
}
@keyframes morph-draw {
  0%,  60%  { stroke-dashoffset: 26; opacity: 0; }
  64%       { stroke-dashoffset: 26; opacity: 1; }
  76%, 86%  { stroke-dashoffset: 0;  opacity: 1; }
  94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }

Where it breaks — the trap

The longest fight in this set was that 01 and 02 simply did not move. Dropping --mx and --angle into keyframes looks like it should interpolate, but a browser treats an unregistered custom property as a plain string, and strings have no midpoint. Across 24 rendered frames the light simply sat in one of two positions and then vanished. Declaring syntax: "<percentage>" and "<angle>" through @property gave the values a type, and only then did the runner complete a real lap.

The second trap was the seam where a loop ends and starts again. Cutting the runner at 340deg and snapping back to 0deg made the light jump once per lap, so the angle now travels a full 360deg and lands exactly on the next lap's zero. The icon in 06 had the same problem at 180deg — the rewind made the swap visibly run backwards — and it now continues to 360deg instead. Item 04 failed in the opposite direction: with a 320px droplet the button was already full by the fifth rendered frame, which left the wave surface invisible, so the droplet dropped to 260px and the rise was stretched across 52% of the loop until the curved waterline actually crossed the label on camera. Item 05 clipped the descenders of its letters in a 1.1em window and needed 1.25em. Only after all of that did the nine pass the 320px-wide overflow check with zero failures. Everything above ships inside the archive that opens with the password cpm3zu6e, so what you download is the same code printed here. One deliberate exception remains: 09 animates width, not transform. Width is a layout property and normally worth avoiding, but a pill cannot become a real circle without its width changing, so the label is held steady with overflow: hidden and a one-cell grid instead.

Accessibility (reduced-motion)

Under prefers-reduced-motion: reduce all nine drop the decorative loop but keep the signal. The spotlight parks in the center, the runner settles into a quiet ring, the liquid stays filled, and the four brackets stay closed. Item 08 carries the same shape on :focus-visible, so it works without a mouse at all, and item 09 announces the wait with aria-busy="true" and clears it when the work finishes.

State Motion on Motion reduced
Aiming (01, 02, 04, 05, 06) Light, liquid, and letters cycle on a 2s loop Frozen at the end pose, contrast unchanged
Pressing (03, 07) Pull and squash run back to back Color and shadow only, no travel or scale
Focus and waiting (08, 09) Brackets close in, the check draws itself Brackets stay closed, the check appears at once

The rule that a custom property must be registered before it can animate is documented in the MDN page for @property. More effects with the same trigger live in the hover category, and the ones aimed at conversion buttons are in the CTA category.

FAQ

How many of these work without JavaScript?

Five of the nine are pure CSS, with no script tag at all. Of the rest, only 01 and 03 read a real pointer coordinate, each in under twenty lines of plain JavaScript, while 05 builds its letter cells and 09 toggles a busy class. Every one of those scripts does nothing but set a CSS variable or a class name, so no animation library, no scroll library, and no build step is required for any of the nine.

Touch devices have no hover — are these safe to ship there?

The five meant for hover (01, 02, 04, 05, 06) have nothing to react to on a tap, so on touch-heavy screens it is safer to pair them with something like 07, which produces the same result from :active. Items 07 and 09 rely on press and busy states, so they behave identically under a finger, and 08 reaches keyboard users through :focus-visible.

Where do I change the color and speed for my own brand?

The three variables at the top of each SCSS file — $duration, $easing, and a third that is usually $color ($stagger in 05) — are the whole control surface. The React port takes the same three as props and forwards them as CSS custom properties, so calling the component with duration={1200} is enough, and nothing else in the file has to be touched.

Enter the archive password

The password is inside this article. You will find it as you read.