GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Button Hover Effects — 9 Proximity Patterns

These are css button hover effects. If the cursor is still a hand-width away, the distance value stays large.

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


Why One Distance Value Drives All Nine Buttons

Every demo on this page starts from the same tiny function: measure the pixel distance between the pointer and the button's center, turn that number into a ratio between 0 and 1, and let every visible property read from that one ratio. None of the nine switch a class on and off at a boundary — a css button hover animation generator that only toggles :hover would miss the part where the reaction keeps changing while the cursor is still approaching, before it ever lands on the button. What differs between the nine is only which property reads the ratio, and whether a second calculation layer (a spring, a vector, a rank, a real measured width) sits between the ratio and what you see.

Nine Proximity Patterns, Compared by Moved Area and Intensity

Each demo is captured as 24 frames while a scripted virtual cursor orbits the button, and the delta between consecutive frames is measured as a share of the 480×300 canvas.

# Pattern Cumulative area Avg intensity Moved frames
01 Glow radius grows 20.6% 52.6 22/23
02 Scale and lift 18.8% 47.4 18/23
03 Tilt follows angle 13.2% 52.4 18/23
04 Blur sharpens nearest 18.2% 19.6 16/23
05 Hue shifts on approach 14.5% 46.7 20/23
06 Border traces around 14.9% 46.5 21/23
07 Shadow lifts in depth 7.6% 62.1 21/23
08 Letter spacing expands 26.5% 37.6 21/23
09 Particle density swarms 11.0% 102.3 20/23

Code Per Pattern, Read Straight From index.html

Every block below is copied unchanged from each item's own index.html; only the on-screen badge text is swapped for the English string it actually shows. The auto-play cursor you see in the demo comes from a syntheticOffset function that orbits a virtual point — it's a scripted loop for the page, not a recording of anyone's real pointer.

01Button Whose Glow Widens With the Distance Value

As the distance value between the cursor and the button shrinks, the glow radius around it widens and its brightness climbs at the same time. One proximity ratio (p) feeds three CSS values in the same call — blur radius, spread, and brightness() — so the glow, the spread, and the light all move together instead of drifting out of sync.

Button Whose Glow Widens With the Distance Value
Open in a new tab

01 · Button Whose Glow Widens With the Distance Value — Measured area 20.583% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

PROX_GLOW_RADIUS_MAX_PXdistanceToButtonsyntheticOffset
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
    var spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
    var alpha = (0.18 + p * 0.55).toFixed(2);
    btn.style.boxShadow = '0 0 ' + blurRadius.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(255,247,230,' + alpha + ')';
    btn.style.filter = 'brightness(' + (1 + p * 0.22).toFixed(3) + ')';
  badge.textContent = 'Distance value ' + Math.round((1 - p) * 100) + '%';
  }

Measured: cumulative area 20.6%, average intensity 52.6, moved 22/23 frames.

02Button That Gradually Grows as the Distance Value Shrinks

The cursor's distance value doesn't flip the button's size on and off — a spring (STIFFNESS, DAMPING) chases the target ratio frame by frame, so the size keeps drifting for a moment even after the cursor stops moving. That same spring position is also written to a --tone-p custom property, so the background tone overshoots along with the scale.

Button That Gradually Grows as the Distance Value Shrinks
Open in a new tab

02 · Button That Gradually Grows as the Distance Value Shrinks — Measured area 18.788% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

springStepSTIFFNESSDAMPING
  function render(p) {
    var scale = 1 + p * SL_SCALE_MAX;
    var lift = p * SL_LIFT_MAX_PX;
    var depth = 8 + p * 18;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
    btn.style.boxShadow = '0 ' + depth.toFixed(1) + 'px ' + (depth * 1.6).toFixed(1) + 'px rgba(23,20,26,' + (0.18 + p * 0.18).toFixed(2) + ')';
    // Calculation layer: propagate the same spring overshoot value (p) to the background tone (the glow behind the shadow).
    var tone = clamp(p, 0, 1.15);
    scene.style.setProperty('--tone-p', tone.toFixed(3));
  badge.textContent = 'Distance value ' + Math.round((1 - clamp(p, 0, 1)) * 100) + '%';
  }

  function springStep(target) {
    var force = (target - pos) * STIFFNESS;
    vel = (vel + force) * DAMPING;
    pos += vel;
  }

Measured: cumulative area 18.8%, average intensity 47.4, moved 18/23 frames.

03Tilting Button Whose Distance Value Becomes an Angle Value

The direction from the button's center to the cursor is kept as a vector, not just a scalar distance, then converted into a rotation angle — so the button leans toward the cursor's side before the cursor ever reaches it. The same ux, uy, and p used for the angle are reused, unchanged, to place the border highlight and the depth shadow.

Tilting Button Whose Distance Value Becomes an Angle Value
Open in a new tab

03 · Tilting Button Whose Distance Value Becomes an Angle Value — Measured area 13.244% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

vectorToButtonTA_MAX_DEGPROX_RADIUS_PX
  function render(dx, dy) {
    // Direction vector → angle (atan2) conversion — use the distance value (1 when closest) as the angle magnitude.
    var d = Math.hypot(dx, dy);
    var p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
    var ux = d > 0.001 ? dx / d : 0;
    var uy = d > 0.001 ? dy / d : 0;
    var rotY = ux * p * TA_MAX_DEG;
    var rotX = -uy * p * TA_MAX_DEG;
    var lean = p * 6;
    btn.style.transform = 'translate(' + (ux * lean).toFixed(2) + 'px,' + (uy * lean).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    // Calculation layer: propagate the same tilt angle (ux, uy, p) to the border highlight position and the depth shadow as well.
    var shadowX = ux * p * 16;
    var shadowY = 6 + uy * p * 12;
    var shadowBlur = 14 + p * 22;
    btn.style.boxShadow = shadowX.toFixed(1) + 'px ' + shadowY.toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px rgba(23,20,26,' + (0.2 + p * 0.3).toFixed(2) + ')';
    btn.style.setProperty('--hl-x', (50 + ux * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-y', (50 + uy * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-o', (0.12 + p * 0.5).toFixed(2));
    var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
  badge.textContent = 'Angle value ' + Math.round(rotY) + '°';
    void angleDeg;
  }

Measured: cumulative area 13.2%, average intensity 52.4, moved 18/23 frames.

04Neighboring Buttons That Blur as the Distance Value Grows

Instead of measuring each button against a fixed radius alone, every button's distance is sorted against every other button's distance first — the closest one always gets rank 0, and blur is driven by the worse of "raw proximity" and "rank," so the nearest button clears even when two buttons sit close together. Rank is what keeps exactly one button sharp at a time, not distance alone.

Neighboring Buttons That Blur as the Distance Value Grows
Open in a new tab

04 · Neighboring Buttons That Blur as the Distance Value Grows — Measured area 18.241% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

updateTargetscentersOfBF_BLUR_MAX_PX
  function centersOf() {
    return btns.map(function (b) {
      var r = b.getBoundingClientRect();
      return [r.left + r.width / 2, r.top + r.height / 2];
    });
  }

  function updateTargets(px, py) {
    var centers = centersOf();
    var dists = centers.map(function (c) { return Math.hypot(px - c[0], py - c[1]); });
    // Distance sort: rank the buttons by distance, and reduce blur further for lower (closer) ranks.
    var order = dists.map(function (d, i) { return i; }).sort(function (a, b) { return dists[a] - dists[b]; });
    order.forEach(function (idx, rank) {
      var near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
      var byRank = rank === 0 ? 0 : Math.min(1, rank / (btns.length - 1));
      var mix = Math.max(byRank, 1 - near);
      targetBlur[idx] = mix * BF_BLUR_MAX_PX;
    });
  }

Measured: cumulative area 18.2%, average intensity 19.6, moved 16/23 frames.

05Button Whose Distance Value Becomes a Color-interpolation Point

The distance ratio isn't read as a size or an angle here — it's used as the mix point t between two fixed RGB triples, so the button's own color is a linear interpolation, not a CSS transition on background-color. The same interpolated color is reused, channel for channel, to color the ring around the button and the background wash behind it.

Button Whose Distance Value Becomes a Color-interpolation Point
Open in a new tab

05 · Button Whose Distance Value Becomes a Color-interpolation Point — Measured area 14.462% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

lerpColorCOLOR_FARCOLOR_NEAR
  function lerpColor(c1, c2, t) {
    return [
      Math.round(c1[0] + (c2[0] - c1[0]) * t),
      Math.round(c1[1] + (c2[1] - c1[1]) * t),
      Math.round(c1[2] + (c2[2] - c1[2]) * t)
    ];
  }

  function render(p) {
    var c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
    btn.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
    // Calculation layer: propagate the same interpolated color to the border (the spreading ring) and the whole card's background tone.
    var ringPx = (6 + p * 20).toFixed(1);
    var ringA = (0.14 + p * 0.26).toFixed(2);
    btn.style.boxShadow = '0 0 0 ' + ringPx + 'px rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + ringA + ')';
    scene.style.background = 'radial-gradient(circle, rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (0.1 + p * 0.22).toFixed(2) + '), transparent 72%)';
  badge.textContent = 'Interpolation point ' + Math.round(p * 100) + '%';
  }

Measured: cumulative area 14.5%, average intensity 46.7, moved 20/23 frames.

06Button Whose Border Is Drawn by the Distance Value

The outline isn't a border at all — it's an SVG rect whose real perimeter is read once with getTotalLength(), so the dash length used for the draw-on effect is a measured number, not a guessed one. strokeDashoffset then reads the same distance ratio to reveal that exact length, stroke by stroke, as the cursor closes in.

Button Whose Border Is Drawn by the Distance Value
Open in a new tab

06 · Button Whose Border Is Drawn by the Distance Value — Measured area 14.921% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

getTotalLengthstrokeDashoffsettotalLength
  // The shape's real perimeter length (px) — not a guessed value, but one measured directly by SVG.
  var totalLength = rect.getTotalLength();
  rect.style.strokeDasharray = String(totalLength);

  var targetP = 0;
  var curP = 0;
  var hasPointer = false;

  function distanceToButton(x, y) {
    var r = wrap.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var offset = (1 - p) * totalLength;
    rect.style.strokeDashoffset = String(offset);
    rect.style.opacity = String(0.2 + p * 0.8);
  badge.textContent = 'Border value ' + Math.round(p * 100) + '%';
    // Calculation layer: propagate the same progress value (p) to the glow around the ring and the button's background highlight angle (gradient angle) as well.
    wrap.style.setProperty('--p', p.toFixed(3));
  }

Measured: cumulative area 14.9%, average intensity 46.5, moved 21/23 frames.

07Button That Lifts by the Distance Value's Shadow

Offset and blur read the same distance value, but they aren't independent — blur is defined as offset times a fixed multiplier, so the shadow always keeps one visual relationship no matter how close the cursor gets. That single relationship is what makes the css button hover effects codepen crowd often copy this pattern first: it reads as depth, not just a bigger blob under the button.

Button That Lifts by the Distance Value's Shadow
Open in a new tab

07 · Button That Lifts by the Distance Value's Shadow — Measured area 7.634% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

PROX_SHADOW_LIFT_MAX_PXdistanceToButtonrender
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  // Offset and blur as functions of distance — both branch out from the same value (p)
  function render(p) {
    var offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
    var blur = offsetY * 1.7;
    var lift = p * 8;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)';
    btn.style.boxShadow = '0 ' + offsetY.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(23,20,26,' + (0.22 + p * 0.24).toFixed(2) + ')';
  badge.textContent = 'Distance value ' + Math.round((1 - p) * 100) + '%';
  }

Measured: cumulative area 7.6%, average intensity 62.1, moved 21/23 frames.

08Button Whose Distance Value Stretches Letter Spacing

The distance ratio is written straight to letterSpacing in em, but the layer that matters comes after: once the letters have actually spread, the label's real rendered width is read back with getBoundingClientRect() and used to size the underline and the glow — not a formula that assumes how wide the spread text will be.

Button Whose Distance Value Stretches Letter Spacing
Open in a new tab

08 · Button Whose Distance Value Stretches Letter Spacing — Measured area 26.463% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

LS_MAX_EMletterSpacingsyntheticOffset
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  // Convert the distance value into a letter-spacing value — 0 when far, widening as it gets closer
  function render(p) {
    var spacing = p * LS_MAX_EM;
    label.style.letterSpacing = spacing.toFixed(3) + 'em';
    btn.style.filter = 'brightness(' + (1 + p * 0.12).toFixed(3) + ')';
    // Calculation layer: propagate the actual widened letter width (getBoundingClientRect) to the label background and underline length.
    var w = label.getBoundingClientRect().width;
    underline.style.width = w.toFixed(1) + 'px';
    btn.style.setProperty('--underline-o', (0.2 + p * 0.6).toFixed(2));
    btn.style.setProperty('--label-w', w.toFixed(1) + 'px');
    btn.style.setProperty('--label-o', (0.12 + p * 0.4).toFixed(2));
    // Calculation layer: propagate the same progress value (p) to the button's glow shadow radius and opacity as well.
    var spread = (6 + p * 26).toFixed(1);
    btn.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + (0.16 + p * 0.42).toFixed(2) + ')';
  badge.textContent = 'Spacing value ' + spacing.toFixed(2) + 'em';
  }

Measured: cumulative area 26.5%, average intensity 37.6, moved 21/23 frames.

09Button Around Which Particles Gather by the Distance Value

The visible particle count is a floating ratio (p * PROX_PARTICLE_MAX_COUNT), not an integer switched on one at a time — each particle only draws once its own index clears that ratio, so the swarm fills in as a continuous edge rather than popping in dot by dot. Angle and radius both carry a per-particle noise and phase offset, so no two particles orbit identically even at the same distance value.

Button Around Which Particles Gather by the Distance Value
Open in a new tab

09 · Button Around Which Particles Gather by the Distance Value — Measured area 11.006% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

PROX_PARTICLE_MAX_COUNTdrawglobalAlpha
  function draw(p, t) {
    var w = canvas.width, h = canvas.height;
    ctx.clearRect(0, 0, w, h);
    var cx = w / 2, cy = h / 2;
    var count = p * PROX_PARTICLE_MAX_COUNT;
    var radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
    var positions = [];
    particles.forEach(function (pt, i) {
      var visible = clamp(count - i, 0, 1);
      if (visible <= 0) { return; }
      var ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
      var rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
      var x = cx + Math.cos(ang) * rad;
      var y = cy + Math.sin(ang) * rad * 0.72;
      positions.push({ x: x, y: y, size: pt.size * radiusScale * 1.3, alpha: (0.4 + p * 0.6) * visible });
    });

Measured: cumulative area 11.0%, average intensity 102.3, moved 20/23 frames.

Where a Proximity-driven Button Actually Earns Its Keep

A landing page CTA (items 01 and 09) benefits from a glow or a particle swarm that grows before the click, since it rewards the cursor for simply approaching. A card list (items 02, 04, 07) reads better with the scale-lift or the blur-focus pattern, since only one card needs to read as "selected" among several sitting close together. A product card's pre-reaction (item 03) and an accent color swap (item 05) both suit a single hero button rather than a row of equal siblings. People searching css button hover effects codepen usually want one of these nine copy-pasted whole, not rebuilt from a description — that's exactly what the zip behind this page holds.

Three Variants and the Shape of Their Answers

Variant Value What should change
Adjust the glow radius max value 01 PROX_GLOW_RADIUS_MAX_PX 40 → 80 Shortening the value should keep the glow small even at closest approach, reading as subtle; lengthening it should spread the glow wide enough to cover the whole button, reading as pronounced.
Adjust the shadow lift max value 07 PROX_SHADOW_LIFT_MAX_PX 24 → 48 Shortening the value should keep the shadow tight beneath the button, reading as a low hover; lengthening it should spread the shadow further out, making the button read as lifted much higher.
Adjust the particle max-count value 09 PROX_PARTICLE_MAX_COUNT 6 → 14 Shortening the value should keep particles sparse even at closest approach, reading as restrained; lengthening it should gather particles densely, making the area around the button read as crowded and showy.

Accessibility: What prefers-reduced-motion Actually Skips

Every item checks window.matchMedia('(prefers-reduced-motion: reduce)') once on load. When it's set, the requestAnimationFrame loop that drives the scripted auto-demo cursor never starts at all — the orbiting syntheticOffset point that you see moving in a normal browser session simply doesn't run. Real interaction still works: a real pointermove, focus, or blur calls render() (or springStep for item 2) with the target value directly inside the event handler, so the button snaps straight to its resting state instead of easing into it — see MDN's prefers-reduced-motion reference for the underlying media feature.

Where This Trap Breaks on Touch Input and Underpowered Devices

None of these nine read a real user's pointer position anywhere in this demo — the moving cursor you're watching is syntheticOffset's scripted orbit, not a recording of anyone's mouse. A touch input environment never fires a continuous pointermove before contact the way a mouse does, so a finger lands directly at p = 1 with no approach phase at all; a css button hover animation codepen built and tested only with a mouse in mind will look like it's "missing" every in-between frame on a touch input environment, when really there just isn't a hover state to interpolate through. On an underpowered device, item 9's ctx.clearRect plus per-frame trigonometry for up to 14 particles is the one pattern worth watching first, since it's the only item doing canvas work instead of reading a CSS custom property. Getting the real HTML, CSS, and JS files out of the zip needs the zfbz3x5g written a little further down this page.

How Are CSS Button Hover Effects Different From CSS Button Hover Animation?

A search for css button hover animation usually returns a single keyframe or transition triggered at the :hover boundary — the button is either in its rest state or its hover state, with a fixed-duration tween between the two. Every pattern on this page instead reads a continuous distance value while the cursor is still approaching, so the reaction is already partway done before the cursor ever crosses onto the button. The nine here are hover effects in that broader sense: proximity-aware, not boundary-triggered.

Three Questions Worth Answering Before You Ship One

Does a proximity-driven button still work with keyboard-only navigation?

Yes — every item listens for focus and blur the same way it listens for pointermove and pointerleave, setting the target value straight to 1 on focus and 0 on blur, so tabbing to the button reaches the same resting state a mouse approach would.

Why does item 9's average intensity measure so much higher than the others?

Item 9 redraws a <canvas> with clearRect and repaints every visible particle from scratch each frame, so the pixels that do change tend to change by a larger amount at once, compared with a CSS property that eases smoothly between two close values.

Where can I find more css button hover effects examples like these nine?

Two related sets on this site cover the same button, different physics: a press-and-release spring at Button Press Animation UI: 9 Calculation Patterns and a pointer-driven 3D tilt at 9 CSS 3D Button Effects — Copy-paste Ready.

Where to Find More css button hover effects examples Than These Nine

Beyond the two linked above, a canvas-pixel take on the same button family sits at Button Click Effect CSS: 9 Canvas Pixel Patterns, which answers the click moment this page deliberately leaves out — everything here stops at proximity, before any click ever lands.

Enter the archive password

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