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

CTA Button Design: 9 Interaction Patterns

One shadow length value grows and shrinks with hover state, one perimeter progress value depletes to zero over time, and one confirms only once a dragged

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

None of the nine ever reach a payment processor or a real notification service — 09's tap is a simulated meter reading, not a real Web Audio capture, and nothing in the set calls fetch. Every one of the nine also answers something a visitor actually does: 01 and 07 answer a hovered or focused state through mouseenter/mouseleave and focus/blur, 02 answers a hovered or focused state through mouseenter and focus plus a click, 03, 04, 08, and 09 answer a plain click, 05 answers a click plus keydown, and 06 answers a dragged pointerdown-pointermove-pointerup sequence — not one of the nine only plays on autoplay.

Anatomy of nine patterns — which value changes and when

Nine patterns split into three ways of turning a hover, a click, or a drag into a value worth animating, and skipping this layer is what makes a cta button animation collection read as nine identically colored rectangles instead of nine small calculators built into a single button.

Why three patterns solve a physics or trig equation directly from elapsed time

03, 05, and 08 never touch a bounded progress ratio at all — each one solves a time-based equation instead. 03's tick() integrates GRAVITY (420) and DRAG (0.985) into every particle's velocity each frame, so the scatter reads as thrown debris settling under real gravity rather than a scripted arc. 05's ease() plugs elapsed seconds into a sine term that decays by DECAY (9) while oscillating at FREQ (14), which is what lets the flipped face swing past 180 degrees before it settles. 08's frame() runs elapsed time through easeInOutSine() to sweep a highlight band back and forth on a fixed CYCLE (1100ms), never a straight linear scrub.

Why three patterns pull a value toward a moving target with the same smoothing shape

01, 06, and 09 all repeat one shape: a difference between a target and a current value gets multiplied by a fixed factor and added back every frame until the gap closes. 01's step() eases the shadow's len toward target by diff * 0.28. 06's settle() eases a dragged pos back toward 0 or 1 by diff * 0.3 once the pointer releases. 09's tick() eases each bar's height back down toward MIN_H by diff * DECAY (0.14) after a tap pushes it up. Only the factor and the target differ between the three.

Why three patterns key their motion to an index, a tick count, or a time ratio instead

02, 04, and 07 each compute a value from something other than a physics term. 02's runGlitch() checks elapsed ticks against a fixed TICK (40ms) and DURATION (320ms) pair to decide when to reset the offset. 04's frame() divides elapsed time by DURATION (1500ms) into a 0-1 progress ratio that drives strokeDashoffset directly. 07's per-character delay is keyed to each character's own index through Math.pow(i, 0.82) * BASE, not to elapsed time at all — the last character on screen, at index 9, waits over 400ms longer than the first no matter how fast the frame loop runs.

Code per pattern

01Long-shadow retro — a button whose trailing shadow length value extends and retracts with hover state

The shadow's length value grows toward a target length once the button enters a hovered state, and returns to its original length value once the hovered state ends.

Long-shadow retro — a button whose trailing shadow length value extends and retracts with hover state
Open in a new tab

01 · Long-shadow retro — a button whose trailing shadow length value extends and retracts with hover state — Measured area 2.695% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAXLEN=22buildShadowlerp

What the numbers say

Across 23 tracked frames, 01 moves on 8 of them, covering 2.70% cumulative area at a 166.3 average intensity — the visible change is a stack of 1px-offset shadow layers recolored from a near color toward a far color, and the diff * 0.28 smoothing in step() is what keeps the shadow easing toward its target length instead of snapping there in one frame.

function buildShadow(l) {
  var n = Math.round(l);
  if (n <= 0) { return 'none'; }
  var parts = [];
  for (var i = 1; i <= n; i++) {
    var t = i / n;
    var r = Math.round(lerp(C_NEAR[0], C_FAR[0], t));
    var g = Math.round(lerp(C_NEAR[1], C_FAR[1], t));
    var b = Math.round(lerp(C_NEAR[2], C_FAR[2], t));
    parts.push(i + 'px ' + i + 'px 0 rgb(' + r + ',' + g + ',' + b + ')');
  }
  return parts.join(',');
}

function step() {
  var diff = target - len;
  len += diff * 0.28;
  if (Math.abs(diff) < 0.05) { len = target; }
  btn.style.boxShadow = buildShadow(len);
  if (Math.abs(target - len) > 0.05) {
    raf = requestAnimationFrame(step);
  } else {
    raf = null;
  }
}

02Split offset — a button whose stacked text layers momentarily offset in position value before realigning

The moment a hovered state begins, the position value of two stacked text layers offsets by a set distance for an instant, then returns to the same position value once a set duration passes.

Split offset — a button whose stacked text layers momentarily offset in position value before realigning
Open in a new tab

02 · Split offset — a button whose stacked text layers momentarily offset in position value before realigning — Measured area 0.914% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

SEED=20260920DURATION=320mulberry32

What the numbers say

Across 23 tracked frames, 02 moves on 7 of them, covering 0.91% cumulative area at a 115.5 average intensity — runGlitch() reads a seeded mulberry32() generator every TICK (40ms) until DURATION (320ms) elapses, so a fixed SEED reproduces the identical offset sequence on every hover instead of a different glitch each time.

function runGlitch() {
  if (glitchTimer) { return; }
  var elapsed = 0;
  glitchTimer = setInterval(function () {
    elapsed += TICK;
    if (elapsed >= DURATION) {
      clearInterval(glitchTimer); glitchTimer = null;
      layerA.style.transform = 'translate(0px,0px)';
      layerB.style.transform = 'translate(0px,0px)';
      return;
    }
    var ax = (rng() - 0.5) * 10;
    var ay = (rng() - 0.5) * 4;
    var bx = (rng() - 0.5) * 10;
    var by = (rng() - 0.5) * 4;
    layerA.style.transform = 'translate(' + ax.toFixed(1) + 'px,' + ay.toFixed(1) + 'px)';
    layerB.style.transform = 'translate(' + bx.toFixed(1) + 'px,' + by.toFixed(1) + 'px)';
  }, TICK);
}

03Burst scatter — pieces scatter from the click point at differing angle values before fading

Starting from the click point, several pieces spread out each with a different angle value and speed value, and their opacity value drops over time until they reach a faded state.

Burst scatter — pieces scatter from the click point at differing angle values before fading
Open in a new tab

03 · Burst scatter — pieces scatter from the click point at differing angle values before fading — Measured area 3.59% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRAVITY=420COLORSburst

What the numbers say

Across 23 tracked frames, 03 moves on 15 of them, covering 3.59% cumulative area at a 62.8 average intensity — burst() launches 18 particles at differing angle and speed values from the click point, and tick() integrates GRAVITY (420) and DRAG (0.985) into each particle's velocity every frame until LIFE (900ms) fades it out.

function burst() {
  var cx = canvas.width / 2, cy = canvas.height * 0.35;
  particles = [];
  for (var i = 0; i < 18; i++) {
    var angle = (Math.PI * 2 * i) / 18 + (Math.random() - 0.5) * 0.5;
    var speed = 90 + Math.random() * 110;
    particles.push({
      x: cx, y: cy,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - 60,
      rot: Math.random() * Math.PI,
      vrot: (Math.random() - 0.5) * 10,
      color: COLORS[i % COLORS.length],
      age: 0
    });
  }
  if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(tick); }
}

04Perimeter countdown — a button whose surrounding perimeter progress value depletes over a set duration

The progress value of the perimeter surrounding the button decreases from its starting value to zero over a set duration, and once that value reaches zero it moves to a set action.

Perimeter countdown — a button whose surrounding perimeter progress value depletes over a set duration
Open in a new tab

04 · Perimeter countdown — a button whose surrounding perimeter progress value depletes over a set duration — Measured area 1.598% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

R=38CIRCstartCountdown

What the numbers say

Across 23 tracked frames, 04 moves on 20 of them, covering 1.60% cumulative area at a 150.9 average intensity — frame() divides elapsed time by DURATION (1500ms) into a 0-1 progress ratio that drives the ring's strokeDashoffset down from the full circumference to zero, and reaching zero is what fires the completion label below.

function frame(now) {
  var t = now - startT;
  var progress = Math.min(1, t / DURATION);
  var offset = CIRC * (1 - progress);
  bar.style.strokeDashoffset = offset.toFixed(2);
  if (progress < 1) {
    raf = requestAnimationFrame(frame);
  } else {
    running = false; raf = null;
    btn.textContent = 'Completed';
    setTimeout(function () { btn.textContent = 'Next step'; bar.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
  }
}
function startCountdown() {
  if (running) { return; }
  running = true;
  startT = performance.now();
  raf = requestAnimationFrame(frame);
}

053D flip face — a button whose rotation value turns to 180 degrees on click, revealing its opposite face

On click, the button's rotation value turns until it reaches 180 degrees, revealing the opposite face, passing through a state where the rotation value slightly overshoots the target before settling back.

3D flip face — a button whose rotation value turns to 180 degrees on click, revealing its opposite face
Open in a new tab

05 · 3D flip face — a button whose rotation value turns to 180 degrees on click, revealing its opposite face — Measured area 5.267% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

OVERSHOOT_DEG=18flipease

What the numbers say

Across 23 tracked frames, 05 moves on 16 of them, covering 5.27% cumulative area at a 110.7 average intensity — ease() folds elapsed seconds into a sine term scaled by OVERSHOOT_DEG (18) and damped by DECAY (9) at a frequency of FREQ (14), so the face's rotateY() value swings past 180 degrees before it settles, all inside a fixed DUR (500ms).

function ease(tSec) {
  if (tSec <= 0) { return 0; }
  var settle = 1 - Math.exp(-DECAY * tSec);
  var wobble = Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec) * (OVERSHOOT_DEG / 180);
  return settle + wobble;
}
function frame(now) {
  var t = (now - startT) / 1000;
  var p = Math.min(1, ease(t));
  current = base + p * 180;
  btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
  if (t < DUR / 1000) {
    raf = requestAnimationFrame(frame);
  } else {
    base = base + 180;
    current = base;
    btn.style.transform = 'rotateY(' + current.toFixed(2) + 'deg)';
    raf = null;
  }
}

06Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a threshold

Once the dragged position value passes a set threshold value, it changes to a confirmed state; if released while still below the threshold, the position value returns to its starting place.

Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a threshold
Open in a new tab

06 · Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a threshold — Measured area 5.158% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESH=0.7THUMB_W=40settle

What the numbers say

Across 23 tracked frames, 06 moves on 16 of them, covering 5.16% cumulative area at a 218.7 average intensity — the highest intensity of the nine, since a dragged 40px handle repaints on every pointermove, and once released below THRESH (0.7) this same diff * 0.3 smoothing eases the handle back to its start instead of leaving it wherever the pointer let go.

function settle() {
  var diff = target - pos;
  pos += diff * 0.3;
  if (Math.abs(diff) < 0.002) { pos = target; }
  apply(pos);
  if (Math.abs(target - pos) > 0.002) {
    raf = requestAnimationFrame(settle);
  } else {
    raf = null;
  }
}

07Sequential reveal — a button whose second phrase appears character by character following an order value on hover

Once a hovered state begins, each character of the second phrase appears in turn after a delay proportional to its own order value, and once the hovered state ends it returns to the first phrase.

Sequential reveal — a button whose second phrase appears character by character following an order value on hover
Open in a new tab

07 · Sequential reveal — a button whose second phrase appears character by character following an order value on hover — Measured area 0.943% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE=70TEXTreveal

What the numbers say

Across 23 tracked frames, 07 moves on 10 of them, covering 0.94% cumulative area at a 64.6 average intensity — each character's own index feeds Math.pow(i, 0.82) * BASE (70) to set its own transitionDelay, so the phrase reveals left to right at a slightly slowing pace instead of every character firing on the same delay.

TEXT.split('').forEach(function (ch, i) {
  var span = document.createElement('span');
  span.textContent = ch === ' ' ? ' ' : ch;
  span.style.transitionDelay = (Math.pow(i, 0.82) * BASE).toFixed(0) + 'ms';
  second.appendChild(span);
});
var maxDelay = Math.pow(TEXT.length - 1, 0.82) * BASE + 260;

function reveal() {
  second.classList.add('is-shown');
  first.classList.add('is-hidden');
}
function hide() {
  second.classList.remove('is-shown');
  first.classList.remove('is-hidden');
}

08Flowing wait — a button whose surface brightness value sweeps in one direction while content isn't ready

While the content is in a not-ready state, the surface's brightness value repeatedly sweeps from one end to the other, and once the content reaches a ready state the sweep stops and the real content appears.

Flowing wait — a button whose surface brightness value sweeps in one direction while content isn't ready
Open in a new tab

08 · Flowing wait — a button whose surface brightness value sweeps in one direction while content isn't ready — Measured area 6.108% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAND=34CYCLE=1100easeInOutSine

What the numbers say

Across 23 tracked frames, 08 moves on 20 of them, covering 6.11% cumulative area at the lowest average intensity of the nine, 38.7 — easeInOutSine() eases a highlight band 34% wide back and forth across a fixed CYCLE (1100ms), a low-contrast sweep that covers more of the button's surface over time than any other item here without ever reading as a sharp change.

function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; }

function frame(now) {
  var t = ((now - startT) % CYCLE) / CYCLE;
  var e = easeInOutSine(t);
  var pos = -BAND + e * (100 + BAND * 2);
  sweep.style.transform = 'translateX(' + pos.toFixed(1) + '%)';
  if (loading) { raf = requestAnimationFrame(frame); } else { raf = null; }
}

09Bar response — a button where several bars' height values jump by differing amounts on each click before settling

On each click, the height value of each of several arranged bars jumps by a differing amount, and over time each bar's height value decreases until it settles back to its starting height.

Bar response — a button where several bars' height values jump by differing amounts on each click before settling
Open in a new tab

09 · Bar response — a button where several bars' height values jump by differing amounts on each click before settling — Measured area 0.293% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DECAY=0.14MIN_H=0.14hit

What the numbers say

Across 23 tracked frames, 09 moves on all 23 of them, covering 0.29% cumulative area at a 170.7 average intensity — hit() jumps each bar to a random height above MIN_H (0.14) on every click, and tick()'s diff * DECAY (0.14) pull-back never fully stops within the tracked window, which is why this pattern alone never settles into a static frame.

function tick() {
  var moving = false;
  for (var i = 0; i < N; i++) {
    var b = bars[i];
    var diff = MIN_H - b.h;
    if (b.h > MIN_H) {
      b.h += diff * DECAY;
      if (b.h - MIN_H > 0.004) { moving = true; } else { b.h = MIN_H; }
    }
  }
  render();
  if (moving) { raf = requestAnimationFrame(tick); } else { raf = null; }
}
function hit() {
  for (var i = 0; i < N; i++) {
    bars[i].h = Math.max(bars[i].h, MIN_H + Math.random() * 0.82);
  }
  render();
  if (!raf) { raf = requestAnimationFrame(tick); }
}

Where values reshape a pattern — length, progress, and position thresholds

Three constants decide how a pattern feels without changing what it does — swap them and the same hover, click, or drag still lands on the same end state, just along a different path.

Why 06's THRESH decides how far a drag must travel, not how it feels while dragging

onUp() compares the released pos against THRESH (0.7) — above it, the drag confirms; below it, settle() eases the handle back to zero using the exact same diff * 0.3 factor either way. Drop THRESH to 0.4 and the same drag confirms just under halfway across the track, so a light nudge is enough where 0.7 needed a firm pull to the far end.

Why 09's DECAY decides how long a bar's bounce lingers, not how high it jumps

hit() always jumps a bar to the same random ceiling above MIN_H regardless of DECAY — that constant only shapes how fast tick() pulls the bar back down afterward. At 0.14 a struck bar settles over several visible frames; raised to 0.45, the same jump height falls back within two or three frames, reading as sharper and more percussive.

Why 05's OVERSHOOT_DEG decides how far the flip wobbles past 180deg, not how long the flip takes

ease()'s wobble term multiplies OVERSHOOT_DEG / 180 against a decaying sine, while DUR (500ms) alone sets how long the whole flip takes regardless of that value. At 18 the face swings past 180 degrees to nearly 198 before settling back; at 4 it barely overshoots at all, reading as a stiffer, more mechanical rotation.

Where it breaks — rapid clicks, short hovers, releasing midway

What happens on a rapid click

03's burst() replaces the whole particles array on every click, so clicking again mid-burst discards the still-falling pieces from the previous click instead of adding to them. 04's startCountdown() guards with an early return while running is true, so a second click during an active countdown does nothing until the ring finishes. 09's hit() is built for exactly this case — each tap takes the larger of the bar's current height or a fresh random target, so a fast second tap during settle only raises a bar that hasn't yet fallen back to MIN_H.

What happens on a hover shorter than the transition

01's step() keeps easing len toward whatever target was last set — 0 once hover ends — so a hover cut short mid-ease just reverses direction rather than resetting instantly. 07's slowest character, at index 9, waits over 400ms longer than the first before its own transitionDelay fires, so a hover shorter than that window can end and call hide() while some characters of the second phrase haven't appeared yet.

What happens if a drag is released before the threshold

06's onUp() checks pos against THRESH only at the moment of release; below it, settle() eases pos back toward 0 using smoothing the drag itself never applies — the pointermove handler moves the handle by the pointer's raw position with no easing at all, so an active drag tracks the pointer exactly while a released, unconfirmed one animates back on its own.

Where this set differs from 9 CSS Button Animations (SCSS + React, copy-paste), 9 button hover effects css that award sites reuse, and 9 button state css patterns, loading to done is which moment each set actually recomputes something in response to. That first set plays a single transform or opacity change tied to one moment — hover, press, wait, fail, confirm — without folding a dragged distance or a click count into the animation itself. The hover-effect set sorts its nine signals by what each one communicates on entry and exit, but none of them carry a bounded progress ratio or a threshold a visitor can cross partway through. The state-feedback set writes its state into a single ARIA attribute as a button locks, spins, and resolves, closer to what 04's countdown does internally, but that set never derives its animation from a continuously dragged or repeatedly clicked value the way 06 and 09 do here. All nine source files behind this set, unlocked with the password t6t4u9yp, drop straight into a real call-to-action once the placeholder copy above is swapped for a real one.

Three variants and accessibility

Only three of these nine cta button styles expose a constant worth turning into a shipped variant — not because the other six lack tunable numbers, but because THRESH, DECAY, and OVERSHOOT_DEG are the three where a single value change most visibly reshapes how the interaction feels rather than just how fast it plays.

Variant What changes What it trades off
06's THRESH from 0.7 to 0.4 The drag needs to cross less of the track before it confirms At 0.7 a visitor must pull past most of the track to feel sure the drag will confirm; at 0.4 the same drag locks in just past a third of the way, trading that certainty for a lighter touch
09's DECAY from 0.14 to 0.45 A struck bar falls back to its resting height faster At 0.14 the bounce lingers across several frames like a soft strike; at 0.45 the same jump snaps back almost immediately, reading as a harder, more percussive tap
05's OVERSHOOT_DEG from 18 to 4 The flipped face wobbles past 180 degrees by a smaller margin At 18 the reveal has a springy, slightly loose feel; at 4 the same flip reads closer to a stiff, mechanical rotation with almost no bounce

All nine check window.matchMedia('(prefers-reduced-motion: reduce)').matches before starting their own autoplay preview loop — 01, 02, 03, 05, 06, and 07 wait for that loop's first interval tick before animating at all, while 04, 08, and 09 call startCountdown(), startLoading(), and hit() once immediately after the check passes, so the ring, the sweep, and the bars all move the instant the page loads rather than waiting on the interval. wireReal() itself carries no reduced-motion check anywhere in the set, so every hover, click, or drag still plays its full animation by hand regardless of that setting. 02's second glitch layer and 07's second reveal phrase both carry aria-hidden, since each one duplicates text a screen reader already announces once through the visible layer. 06's handle is the only one of the nine with its own aria-label; the rest rely on the button's own visible text as their accessible name.

FAQ

Are these button microinteractions built with any animation library?

No — all nine run on plain requestAnimationFrame, setInterval, or CSS transition with zero external dependencies, so pasting the HTML, SCSS, and JS in as-is is enough to see the same motion running locally.

Do any of the nine call a real payment, analytics, or notification API?

No — every value here is computed locally and rendered straight to the page. 09's meter is a simulated response to a tap, not a real Web Audio reading, and nothing in the set calls fetch or reaches an external service.

Is this a reference to read, or code meant to be pasted into a button?

It's meant to be pasted in — each of the nine is 25 lines or fewer of the core computation shown above, and the invented labels and colors are the only parts meant to be swapped for a real product's own copy and palette.

Sources: MDN — CSS transition · MDN — Window.requestAnimationFrame · MDN — transform-function: rotateY() · MDN — prefers-reduced-motion

Enter the archive password

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