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

CSS Button Color Transition: 9 Hover Demos

One line recolors a button on hover, yet it muddies the midpoint, cannot move a gradient and loses the label midway — scenes 02, 04 and 07 each close one gap.

See All Nine Color-Change Buttons Running on This Page

Each cell in the grid plays a short scripted hover on its own, and the first real pointer move or key press switches that script off and feeds your input into the same calculation. Nothing is fetched and every color pair is a constant, so what you watch is only how a color travels from one value to the other.

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

CSS Button Color Transition: 9 Demos Where the Color Is Computed, Not Just Switched

The nine run from the plainest way a color can arrive to the most layered. Scene 01 closes a fixed share of the remaining distance every frame, and 02 sets a plain blend beside a brightness-corrected one on the same two colors. Scene 03 pushes a gradient boundary with a spring, 04 stacks two gradient layers and fades the upper one, and 05 turns the gradient angle toward the pointer. Scene 06 spins the hue faster the longer you hold, 07 flips the label from white to black as the fill brightens, 08 sends a slanted highlight across once, and 09 lets the shadow take its tint from the fill.

Where These Buttons Fit

Any screen with a main action button that changes on hover can use them. For a css button color change on hover that has to stand out, such as the buy button on a price card, the gradient scenes 03 and 08 are the closest fit. A tool that lets people choose a color wants the hold-to-spin input of 06, and a theme button whose fill swings between very dark and very light needs the label flip of 07 first. Whichever you pick, you decide one pair of colors and the whole calculation reduces to a single progress number between them.

How This Set Differs from Neighboring Posts

CSS Button Hover Effects — 9 Proximity Patterns splits the reaction by how near the pointer is, and Button Press Animation UI: 9 Calculation Patterns follows the dip and the return of a press. What moves here is neither shape nor depth but the color itself, so all nine scenes hang on one question: how the midpoint gets calculated.

Code for Each Color-Moving Technique

Every scene is built on a native <button>, and scenes 02 and 04 use two of them so the plain and the calculated version sit side by side. Scenes 01, 02, 07 and 09 keep their color pair in CSS variables such as --c-from and --c-to and read it with getComputedStyle; the others hold their values inside the script. In all nine the color is calculated in JS and recalculated every frame inside a requestAnimationFrame loop. The snippets below are the exact lines from the source that do the moving; the rest of the markup and styles is in the archive, and no library or CDN is involved. Anyone who wants a css button color animation to fork can read each fence as a standalone function.

01Base Hover That Pulls the Color Toward Its Target

Each frame multiplies the remaining distance of the three channels by 1 - exp(-dt * PULL_RATE), and once the summed gap falls under SETTLE_GAP the color snaps to the target. There is no timing curve, so releasing the hover midway starts the return from the color currently shown. This scene filled 16.2% of the frame at an intensity of 114.6 and moved in 22/23 frames.

Base Hover That Pulls the Color Toward Its Target
Open in a new tab

01 · Base Hover That Pulls the Color Toward Its Target — Measured area 16.203% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

PULL_RATESETTLE_GAPCYCLE_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var goal = target(now);
    var k = 1 - Math.exp(-dt * PULL_RATE);
    for (var i = 0; i < 3; i += 1) cur[i] += (goal[i] - cur[i]) * k;
    if (gapOf(goal) < SETTLE_GAP) cur = goal.slice();
    paint(goal);
    requestAnimationFrame(tick);
  }

02Gamma-Corrected Mix Beside a Plain Mix

The left button interpolates the raw sRGB values, while the right one converts with toLinear, blends, and returns with toGamma. At 50% progress the plain blend (#a6b9b1) has a relative luminance of about 0.459, lower than both ends (pink 0.493, mint 0.514), and the corrected blend (#c1bcb1) lands at about 0.505, between them. This scene filled 18.1% of the frame at an intensity of 48.3 and moved in 21/23 frames.

Gamma-Corrected Mix Beside a Plain Mix
Open in a new tab

02 · Gamma-Corrected Mix Beside a Plain Mix — Measured area 18.131% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

MIX_RATEtoLineartoGammamixLinear
  function toLinear(c) {
    var s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  }
  function toGamma(l) {
    var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
    return Math.max(0, Math.min(255, s * 255));
  }
  function mixRaw(a, b, t) { return a.map(function (v, i) { return v + (b[i] - v) * t; }); }
  function mixLinear(a, b, t) {
    return a.map(function (v, i) {
      var la = toLinear(v);
      return toGamma(la + (toLinear(b[i]) - la) * t);
    });
  }

03Gradient Stop Pushed by a Spring

The gradient stop --stop goes through a spring set by STIFFNESS and DAMPING, integrated in fixed steps of 1/120 second, so a move from 20% toward 70% overshoots by 11.1 before it comes back. When the pointer leaves, the stop swings below 20% in the opposite direction and then settles. This scene filled 13.7% of the frame at an intensity of 92.8 and moved in 20/23 frames.

Gradient Stop Pushed by a Spring
Open in a new tab

03 · Gradient Stop Pushed by a Spring — Measured area 13.651% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

STIFFNESSDAMPINGSTOP_RESTSTOP_HOVER
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = goal(now);
    acc += dt;
    while (acc >= STEP) {
      vel += (STIFFNESS * (g - pos) - DAMPING * vel) * STEP;
      pos += vel * STEP;
      acc -= STEP;
    }
    paint();
    requestAnimationFrame(tick);
  }

04Crossfading Two Stacked Gradient Layers

The left button swaps a class, so the whole gradient is replaced in one cut at progress 0.5. The right button keeps a gradient underneath and lays a second gradient above it, and only that upper layer's opacity moves at FADE_RATE; the opacity is computed by script here, so this scene shows how two stacked layers mix rather than proving what a stylesheet can do. This scene filled 17.3% of the frame at an intensity of 97.3 and moved in 22/23 frames.

Crossfading Two Stacked Gradient Layers
Open in a new tab

04 · Crossfading Two Stacked Gradient Layers — Measured area 17.253% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

FADE_RATESTART_MSON_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = goal(now);
    amount += (g - amount) * (1 - Math.exp(-dt * FADE_RATE));
    if (Math.abs(g - amount) < 0.004) amount = g;
    paint();
    requestAnimationFrame(tick);
  }

05Gradient Angle Easing Toward the Pointer

The direction from the button center to the pointer becomes an angle through atan2(dx, -dy), and the gradient angle --ang follows it at FOLLOW_RATE, then drifts back to the 135 degrees of REST_ANGLE when the pointer leaves. The difference is folded with ((g - a + 540) % 360) - 180, so a move from 350 to 10 degrees turns the short way. This scene filled 14.7% of the frame at an intensity of 132.5 and moved in 23/23 frames.

Gradient Angle Easing Toward the Pointer
Open in a new tab

05 · Gradient Angle Easing Toward the Pointer — Measured area 14.651% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLLOW_RATEREST_ANGLEORBIT_SPANORBIT_MS
  function demoGoal(now) {
    if (now < START_MS) return REST_ANGLE;
    var c = (now - START_MS) % CYCLE_MS;
    if (c >= ORBIT_MS) return REST_ANGLE;
    return wrap(60 + (c / ORBIT_MS) * ORBIT_SPAN);
  }

  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = root.classList.contains('is-demo') ? demoGoal(now) : goalAngle;
    var diff = ((g - angle + 540) % 360) - 180;
    angle = wrap(angle + diff * (1 - Math.exp(-dt * FOLLOW_RATE)));
    paint();
    requestAnimationFrame(tick);
  }

06Button Whose Hue Accelerates While Held

While you hold the button, ACCEL pushes the turning speed up to MAX_SPEED, and on release it decays by exp(-FRICTION * dt) and stops on whatever hue it reached. A pointer press is captured with setPointerCapture, and a keyboard press counts from a non-repeating keydown of Space or Enter until keyup, so both follow the same path. This scene filled 16.0% of the frame at an intensity of 77.4 and moved in 23/23 frames.

Button Whose Hue Accelerates While Held
Open in a new tab

06 · Button Whose Hue Accelerates While Held — Measured area 15.999% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_SPEEDHUE_STARTHUE_STEPHOLD_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    if (demoPressed(now) || pressed) {
      speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
    } else {
      speed *= Math.exp(-FRICTION * dt);
      if (speed < 0.5) speed = 0;
    }
    hue = (hue + speed * dt) % 360;
    paint();
    requestAnimationFrame(tick);
  }

07Button Whose Text Flips as the Fill Brightens

Every frame the fill moves, the WCAG-style relative luminance is computed, and above FLIP_THRESHOLD the label turns black, below it white. White and black give equal contrast at a luminance of √(1.05×0.05)−0.05 = 0.179, where that contrast is 4.58:1, and along the whole pink-to-mint path the lowest contrast is 4.61:1. This scene filled 16.1% of the frame at an intensity of 82.1 and moved in 23/23 frames.

Button Whose Text Flips as the Fill Brightens
Open in a new tab

07 · Button Whose Text Flips as the Fill Brightens — Measured area 16.139% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLIP_THRESHOLDFILL_RATEluminance
  function channel(c) {
    var s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  }
  function luminance(rgb) {
    return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
  }
  function ratio(a, b) {
    var hi = Math.max(a, b);
    var lo = Math.min(a, b);
    return (hi + 0.05) / (lo + 0.05);
  }
  function paint() {
    var l = luminance(cur);
    var dark = l > FLIP_THRESHOLD;
    btn.style.background = toHex(cur);
    btn.style.color = toHex(dark ? textDark : textLight);
    lumEl.textContent = l.toFixed(2);
    ratioEl.textContent = ratio(l, dark ? lumDark : lumLight).toFixed(1);
  }

08Slanted Sheen Band Sweeping Over the Color

Each time hover switches on, a bright band tilted at 105 degrees travels once from BAND_FROM to BAND_TO in 700 ms, and its strength rises and falls as PEAK_GLOW * sin(π p). The position follows smoothstep, so the band slows at both ends, and when the sweep ends the strength returns to 0 and only the base colors remain. This scene filled 13.6% of the frame at an intensity of 72.3 and moved in 12/23 frames.

Slanted Sheen Band Sweeping Over the Color
Open in a new tab

08 · Slanted Sheen Band Sweeping Over the Color — Measured area 13.553% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWEEP_MSBAND_FROMBAND_TOPEAK_GLOW
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var on = demoOn(now) || pointerIn || kbFocus;
    if (on && !wasOn) sweepAt = now;
    wasOn = on;
    var p = sweepAt < 0 ? 1 : Math.min(1, (now - sweepAt) / SWEEP_MS);
    var eased = p * p * (3 - 2 * p);
    if (p >= 1) paint(BAND_FROM, 0);
    else paint(BAND_FROM + (BAND_TO - BAND_FROM) * eased, PEAK_GLOW * Math.sin(Math.PI * p));
    requestAnimationFrame(tick);
  }

09Button Whose Shadow Takes the Fill Color

One progress value p sets the fill, the shadow color, the shadow height and the blur radius together, and the shadow color is each fill channel multiplied by SHADOW_DARKEN. The height climbs from 4 px to 14 px and the blur from 10 px to 28 px in proportion to p, so the nearer the color gets to its target, the higher the button floats. This scene filled 23.9% of the frame at an intensity of 90.1 and moved in 21/23 frames.

Button Whose Shadow Takes the Fill Color
Open in a new tab

09 · Button Whose Shadow Takes the Fill Color — Measured area 23.888% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHADOW_DARKENSHADOW_ALPHALIFT_HOVERBLUR_HOVERGLOW_RATE
  function paint() {
    var fill = from.map(function (v, i) { return v + (to[i] - v) * p; });
    var shade = fill.map(function (v) { return v * SHADOW_DARKEN; });
    var lift = LIFT_REST + (LIFT_HOVER - LIFT_REST) * p;
    var blur = BLUR_REST + (BLUR_HOVER - BLUR_REST) * p;
    btn.style.background = toHex(fill);
    btn.style.boxShadow = '0 ' + lift.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(' +
      Math.round(shade[0]) + ', ' + Math.round(shade[1]) + ', ' + Math.round(shade[2]) + ', ' + SHADOW_ALPHA + ')';
    hexEl.textContent = toHex(shade);
    yEl.textContent = String(Math.round(lift));
  }

Where the Values Change the Scene — Pull Ratio, Spring Strength, Brightness Threshold

Three constants decide most of how fast a color arrives, how much it wobbles and where the label can be read. Each row below comes from rendering one scene and measuring the 23 gaps between its 24 captured frames.

Item Cumulative area % Mean intensity Moving frames
01 Base pull 16.2% 114.6 22/23
02 Gamma mix 18.1% 48.3 21/23
03 Spring stop 13.7% 92.8 20/23
04 Layer crossfade 17.3% 97.3 22/23
05 Pointer angle 14.7% 132.5 23/23
06 Held hue 16.0% 77.4 23/23
07 Label flip 16.1% 82.1 23/23
08 Sheen band 13.6% 72.3 12/23
09 Shadow follow 23.9% 90.1 21/23

No scene falls under 10% cumulative area. Scene 08 reads 12/23 because one hover sends the band across for 700 ms and then it stops, and 02 has the lowest intensity because its two colors sit close together.

Pull Ratio Sets How Fast the Color Lands

With PULL_RATE at 7 in 01 the time constant is about 143 ms, so the color covers the first stretch quickly and the last part trails. A higher rate lands sooner but shortens the span in which the change reads as a change.

Spring Strength Sets the Overshoot

At STIFFNESS 170 the stop in 03 passes its 70% target by 11.1, peaks at 81.1 and is settled in roughly 0.76 s. With DAMPING held at 11, a strength of 400 grows the overshoot to 19.9 and 50 trims it to 0.8.

Brightness Threshold Sets the Readable Range

The FLIP_THRESHOLD in 07 starts from 0.179, the luminance where white and black tie. Move it away and contrast drops sharply on one side before the label flips.

Where It Breaks — Rapid Hover Repeats, Muddy Midpoints, Unreadable Labels

Overshoot Stacks on Quick Repeats

Scenes 01 and 09 always continue from the current value, so hovering in and out repeatedly never causes a jump. Scene 03 keeps its velocity vel, and when a hover arrives while that speed is still there the overshoot piles up and swings wider than the first time.

A Plain Blend Dips Below Both Ends

The left button in 02 drops in brightness below both ends at the midpoint, which lines up with the brightness dip. Saturation of the two midpoints is similar, about 0.12 and 0.11, so the benefit of the corrected blend is not a stronger color but a midpoint whose brightness does not sink.

Token Ink Labels Miss 4.5 Mid-Path

Scene 07 first used the ink color from the site tokens, and in the crossover its contrast sat at 4.15 to 4.27:1, short of 4.5, so the label uses pure white and black instead. Lowering the threshold to 0.08 sends the lowest contrast down to 2.64:1, and raising it to 0.30 leaves 3.01:1, so a stretch that is hard to read appears.

An Unfolded Angle Turns the Wrong Way

Without the fold in 05, a move from 350 to 10 degrees travels 340 degrees backwards instead of 20, and the gradient makes a full spin. Any interpolation over angles needs that fold next to it.

To change the complete code and SCSS yourself, download the archive and open it with the password 82z8gdmt; each of the nine folders holds one demo page and its stylesheet.

Three Variants and Accessibility

Item Constant Value What changes
01 PULL_RATE 7 → 14 The time constant shrinks from about 143 ms to 71 ms so the color lands almost at once, and a lower value drags the ending out.
03 STIFFNESS 170 → 400 The stop passes its target by 19.9 instead of 11.1 before returning, while 50 overshoots by only 0.8 and stops almost without wobble.
07 FLIP_THRESHOLD 0.179 → 0.08 The label turns black early and contrast sinks to 2.64:1, and 0.30 leaves 3.01:1 at the lowest point.

Every scene is a native button reachable with Tab. Scenes 01, 02, 03, 04, 07, 08 and 09 head for the same target on :focus-visible as on hover, and 06 is pressed with Space or Enter. Scene 05 has no keyboard target because pointer direction is its input, so only the label and the 3px focus ring remain. When prefers-reduced-motion is reduce, no loop starts and the target value is painted the moment an input arrives. In that case 01, 02, 03, 04, 07 and 09 jump to the final color, 05 takes the pointer angle at once, 06 leaps 60 degrees per press and 08 keeps only a still band. Color change is decoration, so no aria-live region is used; the label contrast that does carry information is protected by the calculation in 07, not by the linear-gradient behind it.

Is One transition Line Enough for a Button Color Change?

For a button that swaps one pair of colors, one line is often enough. It does not look after midpoint brightness, a gradient or label contrast, so add the calculation only where those gaps show, as 02, 04 and 07 do.

When you need a different reaction than color, the neighboring posts continue from here. What happens after the click is in 9 button state css patterns, loading to done, and a keyframe-based set of copy-paste buttons is in 9 CSS Button Animations (SCSS + React, copy-paste).

Frequently Asked Questions Before Changing Button Colors

Can a css button color gradient move with a transition?

The scenes here move colors as numbers computed in JS: 04 fades the opacity of an upper gradient layer, 03 moves the stop as a number and 05 moves the angle as a number, so the gradient itself never has to be blended directly.

How Do I Pick the Hover Color for a Gradient Button?

When the two ends have a similar brightness the midpoint tends to sink, so compare them with the blend in 02 first. If the fill brightens a lot, let the label color be calculated as in 07.

Can the Same Color Move Answer Other Inputs?

Yes, because only the target value changes for a non-pointer input: 06 takes a press and 05 takes the pointer direction. Whatever the input, moving from the current value toward a target is the same calculation as 01.

Enter the archive password

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