---
name: micro-interactions
description: Use when you want "Professional, refined, usable" - Small response animations that confirm user actions or guide attention.
---

# Micro-Interactions

> **Category:** Motion / Interaction  -  **Personality:** Professional, refined, usable
>
> **Best use:** Any production UI
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 2/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

Micro-interactions are the tiny, single-purpose animations that **acknowledge a user action the instant it happens** - a heart that pops when liked, a switch knob that springs across, a checkmark that draws itself, a "Copy" button that flips to "Copied!". Individually trivial, together they make an interface feel responsive, alive and trustworthy. The recipe is almost always the same: an action toggles a *state class* (or `aria-*` attribute) and CSS transitions/short keyframes do the rest. They belong in every production UI, which is why this scores high on Professional and low on Exotic.

## Dependencies / CDN

**None - pure CSS + a little vanilla JS.** No animation library is needed; the JS only flips state and restarts one-shot keyframes. The demo loads two display/UI fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

A shared card scaffold, then one self-contained widget per card. Four representative widgets shown (the live demo has eight - like, toggle, checkbox, copy, bell, add-to-cart, star rating, bookmark):

```html
<div class="mi-grid">

  <!-- Like / heart (JS toggles aria-pressed + spawns a burst) -->
  <article class="mi-card">
    <div class="mi-eyebrow"><span>Like</span><span>tap</span></div>
    <div class="mi-widget">
      <button class="mi-likebtn" type="button" aria-pressed="false" aria-label="Like this post">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
      </button>
    </div>
    <p class="mi-foot"><b id="mi-likecount">1,248</b> designers like this</p>
  </article>

  <!-- Toggle switch (role=switch, JS flips aria-checked) -->
  <article class="mi-card">
    <div class="mi-eyebrow"><span>Toggle</span><span>switch</span></div>
    <div class="mi-widget">
      <button class="mi-switch" type="button" role="switch" aria-checked="true" aria-label="Push notifications">
        <span class="mi-knob"></span>
      </button>
    </div>
    <p class="mi-foot">Push notifications &middot; <b id="mi-swstate">On</b></p>
  </article>

  <!-- Checkbox tick (PURE CSS - real input, no JS) -->
  <article class="mi-card">
    <div class="mi-eyebrow"><span>Checkbox</span><span>pure css</span></div>
    <div class="mi-widget">
      <label class="mi-todo">
        <input type="checkbox" class="mi-cbx">
        <span class="mi-box"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5l5 5L20 6.5"/></svg></span>
        <span class="mi-tasktext">Ship release notes</span>
      </label>
    </div>
    <p class="mi-foot">Tick to mark the task done</p>
  </article>

  <!-- Copy -> Copied (two stacked layers crossfade on .is-copied) -->
  <article class="mi-card">
    <div class="mi-eyebrow"><span>Copy</span><span>clipboard</span></div>
    <div class="mi-widget">
      <div class="mi-keyrow">
        <code class="mi-key" id="mi-keyval">sk_live_9f2c&middot;&middot;&middot;&middot;7a</code>
        <button class="mi-copybtn" type="button" aria-label="Copy API key">
          <span class="mi-lyr mi-default">&#128203; Copy key</span>
          <span class="mi-lyr mi-done">&#10003; Copied!</span>
        </button>
      </div>
    </div>
    <p class="mi-foot">Production API secret</p>
  </article>

</div>
```

## CSS

```css
/* --- shared card scaffold --- */
.mi-card{background:#fff;border:1px solid #e8eaf3;border-radius:18px;padding:15px 16px 14px;
  display:flex;flex-direction:column;gap:12px;min-height:186px;position:relative;overflow:hidden;
  box-shadow:0 1px 2px rgba(16,24,40,.05),0 16px 32px -22px rgba(28,33,64,.5);
  transition:transform .25s ease,box-shadow .25s ease}
.mi-card:hover{transform:translateY(-2px)}
.mi-widget{flex:1;display:flex;align-items:center;justify-content:center;width:100%}

/* the single keyframe most widgets share: squash then overshoot */
@keyframes mi-pop{0%{transform:scale(1)}30%{transform:scale(.78)}62%{transform:scale(1.2)}100%{transform:scale(1)}}

/* --- 1. Like + heart burst --- */
.mi-likebtn{position:relative;width:62px;height:62px;border-radius:50%;border:1px solid #e8eaf3;background:#fff;
  cursor:pointer;display:grid;place-items:center;transition:border-color .2s,background .2s,box-shadow .2s}
.mi-likebtn svg{width:28px;height:28px;fill:none;stroke:#c2c8d6;stroke-width:2;transition:fill .2s,stroke .2s}
.mi-likebtn[aria-pressed="true"]{border-color:#fbcdda;background:#fff1f5}
.mi-likebtn[aria-pressed="true"] svg{fill:#f43f6e;stroke:#f43f6e}      /* state class does the work */
.mi-likebtn.is-pop svg{animation:mi-pop .45s cubic-bezier(.2,.8,.3,1.4)}
.mi-spark{position:absolute;left:50%;top:50%;width:7px;height:7px;margin:-3.5px;border-radius:50%;
  background:#f43f6e;opacity:0;pointer-events:none}
.mi-spark.go{animation:mi-spark .56s ease-out forwards}
@keyframes mi-spark{0%{opacity:1;transform:translate(0,0) scale(1)}
  100%{opacity:0;transform:translate(var(--dx),var(--dy)) scale(.2)}}   /* --dx/--dy set per spark in JS */

/* --- 2. Toggle switch: track colour transitions, knob springs across --- */
.mi-switch{position:relative;width:56px;height:31px;border-radius:999px;border:0;background:#cdd3e2;cursor:pointer;
  transition:background .28s}
.mi-switch[aria-checked="true"]{background:#10b981}
.mi-knob{position:absolute;top:3px;left:3px;width:25px;height:25px;border-radius:50%;background:#fff;
  box-shadow:0 2px 6px rgba(20,28,60,.32);transition:transform .3s cubic-bezier(.34,1.56,.64,1)}
.mi-switch[aria-checked="true"] .mi-knob{transform:translateX(25px)}

/* --- 3. Checkbox: pure CSS, the tick is a stroke-dashoffset draw --- */
.mi-todo{display:flex;align-items:center;gap:12px;cursor:pointer;user-select:none}
.mi-cbx{position:absolute;width:1px;height:1px;opacity:0;margin:0}      /* hide native box, keep it focusable */
.mi-box{width:25px;height:25px;border-radius:8px;border:2px solid #c6ccdb;background:#fff;
  display:grid;place-items:center;transition:background .25s,border-color .25s}
.mi-box svg{width:15px;height:13px;fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;
  stroke-dasharray:24;stroke-dashoffset:24;transition:stroke-dashoffset .34s ease .05s}
.mi-cbx:checked~.mi-box{background:#5b54e6;border-color:#5b54e6}
.mi-cbx:checked~.mi-box svg{stroke-dashoffset:0}                        /* draw the check */
.mi-cbx:focus-visible~.mi-box{outline:2px solid #5b54e6;outline-offset:3px}
.mi-cbx:checked~.mi-tasktext{color:#9aa3b6;text-decoration:line-through}

/* --- 4. Copy -> Copied: two stacked layers, crossfade on .is-copied --- */
.mi-copybtn{position:relative;border:0;cursor:pointer;border-radius:9px;padding:9px 12px;color:#fff;
  background:#222a44;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;gap:7px;
  font-weight:700;transition:background .3s}
.mi-lyr{display:inline-flex;align-items:center;gap:7px;transition:transform .3s ease,opacity .3s ease}
.mi-done{position:absolute;inset:0;justify-content:center;transform:translateY(125%);opacity:0}
.mi-copybtn.is-copied{background:#10b981}
.mi-copybtn.is-copied .mi-default{transform:translateY(-125%);opacity:0}
.mi-copybtn.is-copied .mi-done{transform:translateY(0);opacity:1}

/* --- honour reduced-motion: kill the "delight", keep the state change --- */
@media (prefers-reduced-motion: reduce){
  .mi-likebtn.is-pop svg,.mi-spark.go{animation:none}
  .mi-card:hover{transform:none}
}
```

## JavaScript

The JS never animates - it flips a class/attribute and (for one-shots) restarts a keyframe. The whole file is one IIFE; the four handlers below are representative (bell, add-to-cart, star rating and bookmark follow the identical pattern). Checkbox needs **no JS at all**.

```js
(function(){
  var stage = document.querySelector('.mi-stage'); if(!stage) return;
  var reduce = function(){ return window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; };

  // restart a one-shot animation class, then clean it off (no-op when reduced-motion is on)
  function pop(el, cls, ms){
    if(reduce()) return;
    el.classList.remove(cls); void el.offsetWidth;   // force reflow so the animation can replay
    el.classList.add(cls);
    setTimeout(function(){ el.classList.remove(cls); }, ms);
  }

  /* Like: toggle aria-pressed, bump the count, pop + particle burst */
  var like = stage.querySelector('.mi-likebtn');
  if(like){
    var lc = stage.querySelector('#mi-likecount'), base = 1248, liked = false;
    like.addEventListener('click', function(){
      liked = !liked;
      like.setAttribute('aria-pressed', liked ? 'true' : 'false');
      lc.textContent = (base + (liked ? 1 : 0)).toLocaleString('en-US');
      if(liked){ pop(like, 'is-pop', 460); if(!reduce()) burst(like); }
    });
  }
  function burst(host){
    for(var i = 0; i < 7; i++){
      var s = document.createElement('span'); s.className = 'mi-spark';
      var ang = (i / 7) * Math.PI * 2, dist = 26 + Math.random() * 10;
      s.style.setProperty('--dx', (Math.cos(ang) * dist).toFixed(1) + 'px');
      s.style.setProperty('--dy', (Math.sin(ang) * dist).toFixed(1) + 'px');
      host.appendChild(s);
      (function(el){ requestAnimationFrame(function(){ el.classList.add('go'); });
        setTimeout(function(){ el.remove(); }, 640); })(s);
    }
  }

  /* Toggle: a button is the source of truth via aria-checked (CSS reads it) */
  var sw = stage.querySelector('.mi-switch'), swState = stage.querySelector('#mi-swstate');
  if(sw) sw.addEventListener('click', function(){
    var on = sw.getAttribute('aria-checked') !== 'true';
    sw.setAttribute('aria-checked', on ? 'true' : 'false');
    swState.textContent = on ? 'On' : 'Off';
  });

  /* Copy: real clipboard write, flip to the "Copied!" layer, revert after 1.6s */
  var copy = stage.querySelector('.mi-copybtn'), ct;
  if(copy) copy.addEventListener('click', function(){
    var key = stage.querySelector('#mi-keyval');
    try{ if(navigator.clipboard) navigator.clipboard.writeText(key.textContent.trim()); }catch(e){}
    copy.classList.add('is-copied');
    clearTimeout(ct); ct = setTimeout(function(){ copy.classList.remove('is-copied'); }, 1600);
  });
})();
```

## How it works

- **State is the trigger, CSS is the animator.** Every widget exposes its state as a class (`.is-copied`) or an attribute (`aria-pressed`, `aria-checked`, `:checked`). A `transition` on the affected properties means flipping that state animates automatically - no `requestAnimationFrame` loops, no library.
- **One-shot feedback** (the heart pop, the badge bump) uses a short `@keyframes`. To replay it you must remove the class, **force a reflow** (`void el.offsetWidth`), then re-add it - that's the whole job of the `pop()` helper.
- **Spring feel without physics:** an overshoot easing - `cubic-bezier(.34,1.56,.64,1)` for the switch knob, the `mi-pop` keyframe scaling past 1.0 - reads as "springy" far more cheaply than a real spring solver.
- **The checkmark draws** because the SVG path has `stroke-dasharray` equal to its length and an offset of the same amount (hidden); `:checked` sets the offset to `0`, so the dash slides in.
- **Particles** are throwaway spans positioned at the centre with a per-element `--dx/--dy`; they animate out and `remove()` themselves on a timer.

## Customization

- **Palette per action:** keep the chrome neutral and give each interaction one semantic accent (like = rose, toggle = green, copy = slate-to-green, save = violet). Swap the CSS custom properties on the stage to re-theme everything at once.
- **Snappiness:** transition durations of `.2s-.3s` feel responsive; push the overshoot in the easing's third value (`1.56` -> higher) for more bounce, lower it toward `1.0` for a corporate, calmer feel.
- **Burst density:** change the loop count and `dist` in `burst()`; tint the `.mi-spark` background to match the control.
- **Microcopy matters:** "Copied!", "Saved", a count that ticks up - the words are part of the feedback. Keep them short and in the present/past tense of the action.

## Accessibility & performance

- **Use real controls.** Every widget here is a native `<button>` or `<input>`, so keyboard, focus and screen-reader semantics come for free. The toggle is `role="switch"` + `aria-checked`; the like is `aria-pressed`; the stars are a roving-tabindex `radiogroup` driven by Arrow keys.
- **Visible focus:** a single `:focus-visible{outline:2px solid ...;outline-offset:3px}` on the stage keeps keyboard users oriented without showing rings on mouse clicks.
- **`prefers-reduced-motion` is respected twice:** a media query sets the decorative keyframes to `animation:none`, and the JS checks `matchMedia(...)` before spawning particles or running the spinner/fly animation - **the state still updates instantly**, only the flourish is dropped.
- **Cheap to run:** animate only `transform` and `opacity` (GPU-friendly); avoid animating layout/`box-shadow` in hot paths. There are no persistent loops - everything is event-driven and self-cleaning.

## Gotchas

- **Re-triggering a keyframe needs a reflow.** Just removing and re-adding the class in the same frame does nothing; `void el.offsetWidth` between the two is mandatory.
- **Don't hide inputs with `display:none`** - that removes them from the tab order and the accessibility tree. Use the 1px-clip pattern (`.mi-cbx`) so the real checkbox stays focusable while the custom box is what you see.
- **`stroke-dasharray` must match the path length** (or be slightly larger) or the draw will start partway through or leave a gap.
- **`navigator.clipboard` needs a secure context** (HTTPS/localhost) and can reject - wrap it in `try/catch` and never block the visual "Copied!" feedback on its success.
- **More is less.** Micro-interactions earn their keep by being subtle and consistent; a page where everything bounces, glows and bursts at once reads as a toy, not a product. Pick one motion vocabulary and reuse it.
