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

Micro Interactions: 9 Essential UI Patterns

Micro interactions: One toast recomputes its dismiss time from its stack order; one compares each heading's viewport overlap to switch the active item;

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

None of the nine reach a real notification service, a real search index, or a real account setting — every message, search result, and stored value lives inside the demo's own script and resets on reload. All nine also answer something a person actually does, and none run on autoplay alone: 01 (toast queue) and 09 (clipboard copy) fire on a real click, 02 (dialog focus trap) and 03 (keyboard tabs) answer both click and keydown, 04 (scroll-spy TOC) and 05 (intersection reveal) pair a real IntersectionObserver callback with a click on the demo's own controls, 06 (keyboard shortcuts) answers keydown alone, 07 (debounce search) answers real input, and 08 (theme persistence) answers click plus a window resize.

Anatomy of nine interactions — when each value recomputes

Nine interactions split into three ways of turning a click, a scroll position, or elapsed time into a value a browser can act on, and skipping this layer is what makes a UI pattern read as decoration instead of something that responds. Most micro interactions examples online show only the motion; this set also shows the value that motion is computed from.

Why three interactions keep recomputing under elapsed time alone, without waiting for a state flip

01, 07, and 09 each turn a running clock into a value rather than a flag: 01's dismissAfter is recomputed as BASE_MS plus the toast's own stack-order index times STACK_STEP_MS, so a later toast always outlives an earlier one by exactly one step; 07 restarts a requestAnimationFrame loop that recomputes a progress ratio from elapsed time divided by WAIT_MS on every keystroke; and 09 restarts a reset timer once a copy range is selected, so the label reverts to its resting text on a fixed 900ms schedule rather than staying stuck.

Why three interactions wait for a boolean or key-match value before recomputing anything

02, 06, and 08 sit still until something switches: 02 waits for the dialog's open state to flip before it re-reads every [data-f] element inside it and recomputes the tab-order range those elements define; 06 waits for a keydown to produce a combination value, then matches that value against a registered map instead of handling each shortcut on its own; and 08 waits for a toggle click before recomputing the knob's travel distance from a measured rectangle and writing the new value to localStorage.

Why three interactions measure or compare a value across multiple candidates instead of reading a single flag

03, 04, and 05 pull their value from measurement or comparison: 03 re-reads the active tab's actual rendered position and width with getBoundingClientRect() every time selection changes, rather than assuming a fixed layout; 04 compares every heading's intersectionRatio on each observer callback and activates whichever one currently holds the largest value; and 05 compares a single element's visible ratio against a 0.4 threshold exactly once, then locks its revealed state so the comparison never has to run again.

Code per pattern

01Toast queue — a notification list computing dismiss timing from its stack order

Once several toasts are stacked at the same time, each one's dismiss time is recalculated from its display duration plus its position in the queue, and when one exits early the remaining toasts' position values shift up to fill the gap.

Toast queue — a notification list computing dismiss timing from its stack order
Open in a new tab

01 · Toast queue — a notification list computing dismiss timing from its stack order — Measured area 19.387% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

STACK_STEP_MSMAX_ROWS--i

What the numbers say

Across 23 tracked frames the queue changes on 10 of them, covering 19.39% cumulative area at a 34.3 average intensity, since each add or remove only repaints the stacked rows rather than the whole stage. STACK_STEP_MS is the per-position constant added to BASE_MS to get dismissAfter, MAX_ROWS caps the queue at three visible rows, and the --i custom property is what lets every surviving row's position update the instant reindex() renumbers them after a removal.

  function add() {
    var el = document.createElement('li');
    el.className = 'tq__row';
    el.style.setProperty('--i', String(items.length));
    var text = MESSAGES[seq % MESSAGES.length];
    seq += 1;
    var dot = document.createElement('span');
    dot.className = 'tq__dot';
    var span = document.createElement('span');
    span.className = 'tq__text';
    span.textContent = text;
    el.appendChild(dot);
    el.appendChild(span);
    list.appendChild(el);
    var item = { el: el };
    items.push(item);
    if (items.length > MAX_ROWS) { remove(items[0]); }
    var order = items.indexOf(item);
    var dismissAfter = BASE_MS + order * STACK_STEP_MS;
    item.timer = setTimeout(function () { remove(item); }, dismissAfter);
    el.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); remove(item); });
  }

02Dialog focus trap — a modal confining its tab order to a recomputed range

Once a modal opens, the list of focusable elements inside it is recomputed and the tab order is confined to that range, so moving past the last element wraps the focus value back to the first.

Dialog focus trap — a modal confining its tab order to a recomputed range
Open in a new tab

02 · Dialog focus trap — a modal confining its tab order to a recomputed range — Measured area 20.114% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

[data-f]preventScroll--i

What the numbers say

Across 23 tracked frames the dialog changes on 15 of them, covering 20.11% cumulative area at a 73.6 average intensity, since opening the overlay repaints the whole panel and every later Tab press moves the --i marker to a new field. measure() re-reads every [data-f] element instead of assuming a fixed count, and setIndex()'s modulo arithmetic (((i % len) + len) % len) is what lets focus wrap from the last field back to the first without a special-cased branch.

  // Compute layer: every time it opens, re-read the list of focusable elements to set the range (0..len-1),
  // and stepping past that range wraps the value back around (modulo) — it never assumes a fixed count.
  function measure() {
    focusables = Array.prototype.slice.call(dialog.querySelectorAll('[data-f]'));
  }

  function setIndex(i) {
    var len = focusables.length;
    index = ((i % len) + len) % len;
    marker.style.setProperty('--i', String(index));
    focusables[index].focus({ preventScroll: true });
  }

  function open() {
    measure();
    overlay.hidden = false;
    setIndex(0);
  }

03Keyboard tabs — a tab list recomputing its underline position from the active value

Once the arrow keys change the active tab, that tab's horizontal position and width are recomputed and the underline moves to those values, while a tab sitting off-screen shifts the list sideways by that same amount.

Keyboard tabs — a tab list recomputing its underline position from the active value
Open in a new tab

03 · Keyboard tabs — a tab list recomputing its underline position from the active value — Measured area 0.953% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRect--x--w

What the numbers say

Across 23 tracked frames the underline changes on only 8 of them, covering a modest 0.95% cumulative area at a 55.4 average intensity, since a single thin bar moves rather than a whole panel repainting. place() reads the active tab's real rectangle with getBoundingClientRect() and writes the offset into --x and the width into --w, and the same function scrolls the tab strip only when the target tab's edges fall outside the visible range — a pattern common in micro interactions in ui work that has to support more tabs than fit on screen.

  function place(i) {
    var tab = tabs[i];
    var containerRect = tabsEl.getBoundingClientRect();
    var rect = tab.getBoundingClientRect();
    var x = rect.left - containerRect.left + tabsEl.scrollLeft;
    var w = rect.width;
    underline.style.setProperty('--x', x.toFixed(1) + 'px');
    underline.style.setProperty('--w', w.toFixed(1) + 'px');
    var left = x;
    var right = x + w;
    if (left < tabsEl.scrollLeft) { tabsEl.scrollLeft = left; }
    else if (right > tabsEl.scrollLeft + tabsEl.clientWidth) { tabsEl.scrollLeft = right - tabsEl.clientWidth; }
  }

  function select(i) {
    var len = tabs.length;
    index = ((i % len) + len) % len;
    tabs.forEach(function (t, k) {
      t.setAttribute('aria-selected', k === index ? 'true' : 'false');
      t.setAttribute('tabindex', k === index ? '0' : '-1');
    });
    panel.textContent = TEXTS[index];
    place(index);
  }

04Scroll-spy TOC — a table of contents switching its active item from a compared overlap value

Once the page is scrolled, each heading's overlap ratio with the viewport is recomputed, the heading with the largest value becomes active, and the table of contents' highlight moves to that item's position value.

Scroll-spy TOC — a table of contents switching its active item from a compared overlap value
Open in a new tab

04 · Scroll-spy TOC — a table of contents switching its active item from a compared overlap value — Measured area 2.205% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserverintersectionRatio--i

What the numbers say

Across 23 tracked frames the marker changes on 10 of them, covering 2.21% cumulative area at a 136.3 average intensity — a small area moving with a sharp, high-contrast jump rather than a slow fade. The IntersectionObserver callback stores every section's intersectionRatio in an array on each firing, then a linear scan picks whichever index currently holds the largest ratio, so the active link is decided by a comparison across all sections rather than by whichever one crossed a fixed pixel line first.

  // Compute layer: every frame, compare every heading's overlap ratio (intersectionRatio) and pick
  // whichever holds the largest value as active — a compared value decides it, not a fixed scroll position.
  function activate(i) {
    if (i === active) { return; }
    active = i;
    marker.style.setProperty('--i', String(active));
    links.forEach(function (l, k) { l.setAttribute('aria-current', k === active ? 'true' : 'false'); });
  }

  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      var i = Number(entry.target.getAttribute('data-h'));
      ratios[i] = entry.intersectionRatio;
    });
    var best = 0;
    for (var k = 1; k < ratios.length; k++) { if (ratios[k] > ratios[best]) { best = k; } }
    if (ratios[best] > 0) { activate(best); }
  }, { root: pane, threshold: [0, .25, .5, .75, 1] });
  sections.forEach(function (s) { io.observe(s); });

05Intersection reveal — an element that appears once its visibility ratio crosses a threshold

Once an element's visible ratio value crosses a threshold, its reveal computation runs a single time, and even after it exits the viewport again its state value stays revealed and does not run again.

Intersection reveal — an element that appears once its visibility ratio crosses a threshold
Open in a new tab

05 · Intersection reveal — an element that appears once its visibility ratio crosses a threshold — Measured area 21.367% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserverunobserveintersectionRatio

What the numbers say

Across 23 tracked frames the card changes on 8 of them, covering 21.37% cumulative area at a 92.4 average intensity, since the whole card animates in once rather than a small marker sliding. The observer's threshold array ([0, .4, .8]) exists only so the callback fires with a usable ratio near 0.4; the moment entry.intersectionRatio reaches that value, revealed flips to true and io.unobserve(card) runs immediately, which is why scrolling back up afterward never triggers the reveal a second time.

  // Compute layer: the instant the visible-ratio value crosses the .4 threshold, flip the state once and stop observing.
  // Even if the ratio drops again later (scrolling back up), the state value stays put — it never runs twice.
  var io = new IntersectionObserver(function (entries) {
    var entry = entries[0];
    if (!revealed && entry.intersectionRatio >= 0.4) {
      revealed = true;
      card.classList.add('ir__card--in');
      io.unobserve(card);
    }
  }, { root: pane, threshold: [0, .4, .8] });
  io.observe(card);

06Keyboard shortcuts — a hint list matching a key combination value to an action

Once a key combination is entered, that combined value is recomputed and compared against the registered list, and when it matches, the action runs while the matching entry in the on-screen hint list briefly highlights.

Keyboard shortcuts — a hint list matching a key combination value to an action
Open in a new tab

06 · Keyboard shortcuts — a hint list matching a key combination value to an action — Measured area 20.504% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

ctrlKeydata-comboaria-live

What the numbers say

Across 23 tracked frames a hint changes on 12 of them, covering 20.50% cumulative area at an 82.0 average intensity, since a full hint row highlights and a result line updates together. combo() folds a keydown into a single string ('ctrl+' plus the lowercased key), and trigger() looks that string up directly in the ACTIONS map rather than branching on each registered shortcut — the same lookup handles ctrl+s, ctrl+f, and ctrl+d without adding a fourth if.

  // Compute layer: recompute the combination value from the pressed key (modifier + letter) and match it against the registered list.
  // One comparison function judges all three shortcuts instead of handling each one separately.
  function combo(e) {
    var mod = e.ctrlKey || e.metaKey ? 'ctrl+' : '';
    return mod + String(e.key).toLowerCase();
  }

  function trigger(key) {
    var hit = hints.filter(function (h) { return h.getAttribute('data-combo') === key; })[0];
    if (!hit) { return; }
    hints.forEach(function (h) { h.classList.remove('ks__hint--hit'); });
    clearTimeout(flashTimer);
    clearTimeout(resetTimer);
    void hit.offsetWidth;
    hit.classList.add('ks__hint--hit');
    result.textContent = ACTIONS[key];
    resetTimer = setTimeout(function () {
      hit.classList.remove('ks__hint--hit');
      result.textContent = 'Waiting';
    }, 900);
  }

07Debounced search — a search that fires once a wait value finishes counting down from the last input

Every time the input value changes, the wait-time value restarts counting from the beginning, and only once that value counts all the way down with no further input does the search actually run.

Debounced search — a search that fires once a wait value finishes counting down from the last input
Open in a new tab

07 · Debounced search — a search that fires once a wait value finishes counting down from the last input — Measured area 20.589% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAIT_MSrequestAnimationFrame--r

What the numbers say

Across 23 tracked frames the search view changes on 19 of them — the most of the nine — covering 20.59% cumulative area at a 137.1 average intensity, since a progress bar redraws every animation frame while it counts. onInput() cancels any pending raf and timer and restarts both from zero on every keystroke; tick() then recomputes the bar's --r ratio each frame as elapsed time divided by WAIT_MS, so the bar's width is a live measurement rather than a CSS transition riding on a fixed duration.

  var WAIT_MS = 480;
  var ITEMS = ['Apple pie recipe', 'Apple tree pests', 'Making apple juice', 'Banana smoothie', 'Baking banana bread'];
  var timer = null;
  var startedAt = 0;
  var raf = null;
  var autoplay = null;

  // Compute layer: every time the input value changes, restart the remaining wait time from zero (debounce).
  // The progress bar's width is recomputed every frame from actual elapsed time / WAIT_MS — not a fixed transition.
  function tick() {
    var elapsed = Date.now() - startedAt;
    var ratio = Math.min(1, elapsed / WAIT_MS);
    bar.style.setProperty('--r', ratio.toFixed(3));
    if (ratio < 1) { raf = requestAnimationFrame(tick); }
  }

08Theme persistence — a toggle deciding its initial theme from a stored value

Once the theme changes, that value is stored, and once the page is opened again, the stored value is read and the initial theme is set to it before the page is painted.

Theme persistence — a toggle deciding its initial theme from a stored value
Open in a new tab

08 · Theme persistence — a toggle deciding its initial theme from a stored value — Measured area 6.717% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

localStoragegetBoundingClientRect--x

What the numbers say

Across 23 tracked frames the switch changes on 12 of them, covering 6.72% cumulative area at an 84.6 average intensity, since only the knob and the small preview card repaint rather than the full stage. place() measures the track and knob rectangles with getBoundingClientRect() and derives a travel distance rather than hardcoding a percentage, so the knob still lands correctly if the track's width ever changes; every value change is written under the demo-348- prefixed key so a reload can read it back before the first paint.

  // Compute layer: recompute the travel distance from the track and knob's measured rectangles and move by that value in --x (not a fixed %).
  // Every time the value changes, write it to localStorage so the next load can restore the same value.
  function place() {
    var trackRect = track.getBoundingClientRect();
    var knobRect = knob.getBoundingClientRect();
    var travel = trackRect.width - knobRect.width - 6;
    knob.style.setProperty('--x', (dark ? travel : 0).toFixed(1) + 'px');
  }

  function apply(next, persist) {
    dark = next;
    track.setAttribute('aria-pressed', String(dark));
    card.setAttribute('data-theme', dark ? 'dark' : 'light');
    valueEl.textContent = dark ? 'Dark' : 'Light';
    place();
    if (persist) {
      try { window.localStorage.setItem(KEY, dark ? 'dark' : 'light'); } catch (err) { /* ignore */ }
    }
  }

  try {
    var saved = window.localStorage.getItem(KEY);
    if (saved === 'dark') { apply(true, false); } else { apply(false, false); }
  } catch (err) { apply(false, false); }

09Clipboard copy — a basic button switching its label from a success-state value

Once the copy finishes, a computation swaps the label to a completed state, and after a fixed time resets that value, returning the label to its original text.

Clipboard copy — a basic button switching its label from a success-state value
Open in a new tab

09 · Clipboard copy — a basic button switching its label from a success-state value — Measured area 21.3% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

--top--hdata-n

What the numbers say

Across 23 tracked frames the label and sweep change on 8 of them, covering 21.30% cumulative area at a 73.7 average intensity, as range() turns two clicked line numbers into a { top, height } pair from LINE_H and select() writes those values into --top and --h so the sweep element repositions itself instead of animating between fixed states. This demo does not call navigator.clipboard at all — the "success" a visitor sees is entirely the label swap and the timed reset back to its resting text.

  // Compute layer: recompute the range's height and position from the difference between the start-line and end-line values, and place the sweep there.
  // This never calls the real clipboard API — it only mimics a successful copy by switching the label.
  function range(a, b) {
    var lo = Math.min(a, b);
    var hi = Math.max(a, b);
    return { top: (lo - 1) * LINE_H, height: (hi - lo + 1) * LINE_H, lo: lo, hi: hi };
  }

  function select(startN, endN) {
    var r = range(startN, endN);
    sweep.style.setProperty('--top', r.top + 'px');
    sweep.style.setProperty('--h', r.height + 'px');
    sweep.classList.add('cc__sweep--on');
    nums.forEach(function (btn) {
      var n = Number(btn.getAttribute('data-n'));
      btn.classList.toggle('cc__num--sel', n >= r.lo && n <= r.hi);
    });
    label.textContent = r.lo + '-' + r.hi + ' lines copied';
    label.classList.add('cc__label--done');
    clearTimeout(resetTimer);
    resetTimer = setTimeout(function () {
      label.textContent = 'Select a range to copy';
      label.classList.remove('cc__label--done');
    }, 900);
  }

Where values reshape a pattern — order value, ratio value, wait-time value

Change what a pattern's core value represents and the whole interaction reads differently, even with the same markup underneath.

Why toast queue's order value has to keep the whole stack in sync, not just the newest row

01's order value is not stamped once and forgotten — reindex() rewrites every surviving toast's --i the moment one is removed, because a queue that only recomputed the newest arrival's position would leave a visible gap where the removed toast used to sit. Treating the order value as shared state across the whole list, not a per-item constant, is what keeps the stack looking continuous.

Why scroll-spy's compared ratio needs every heading watched at once, not just the nearest one

04 has to keep every section under observation simultaneously, because picking the active heading from a single "nearest to top" measurement breaks down the moment two headings are close together near a fold — comparing all of their intersectionRatio values on every callback is what lets the largest one win even when several are partially visible at once.

Why debounce's wait-time value has to restart, not just extend, on every keystroke

07's WAIT_MS countdown is reset to zero on each keystroke rather than pushed further out, and that distinction matters: an extend-only timer would let a search fire mid-word once total typing time crossed a threshold, while a full restart guarantees the search only runs once real silence in typing has passed.

Where it breaks — rapid input, focus movement, low-end devices

Where this set differs from 9 Copy-to-Clipboard Button UI Patterns, 9 Breadcrumb UI Patterns, and Keyboard Shortcuts UI Design is the value each one recomputes. That copy-button set recomputes a single button's own success-state value across nine label variants. The breadcrumb set instead recomputes a path's own depth and truncation width as segments collapse. The shortcut set recomputes a key-combination match against one registered list. This set's nine patterns recompute something different again — a stack order, a focus range, a measured tab position, a pair of compared ratios, a wait-time countdown, a stored theme value, and a copy-range height. The full nine-pattern implementation behind this set sits behind ntw2bmzv in this post's zip.

What rapid, repeated input does to the debounce timer and the toast queue

07's timer restart is cheap on its own, but firing it on every keystroke of a fast typist means cancelAnimationFrame and clearTimeout run dozens of times before the wait value ever counts down — harmless here, but a production version should still guard against calling search() with an empty query mid-restart. 01's stack has the opposite problem: MAX_ROWS caps visible toasts at three, so a burst of rapid triggers pushes older toasts out through remove() before their own timer even fires, which is intentional but worth documenting for anyone wiring this into a real notification pipeline.

What focus movement does once the dialog trap and the keyboard tabs share a page

02's trap and 03's tab list both move focus programmatically, and stacking them on the same page means a modal opened from inside a tab panel has to hand focus back to the tab that opened it on close, not just to whatever the browser's default focus order suggests — closeBtn's handler already returns focus to openBtn for this reason, and a real integration should do the same for a tab-triggered dialog.

What a low-end device does to nine independent listeners on one page

None of these nine attach a listener to window.scroll directly — 04 and 05 use IntersectionObserver specifically so the browser's compositor decides when to fire rather than running a handler on every scroll frame — but a low-end device running all nine demos on one page at once still divides its frame budget nine ways, so a real page should mount only the patterns it actually uses rather than all nine as a gallery.

Three variants and accessibility

Treat the numbers below as a starting point once every placeholder message, key combination, and stored value has been swapped for real content — the computed value stays the same, only the constant changes.

Variant What changes What it trades off
Debounced search wait time Item 07's WAIT_MS constant from 480ms to 900ms At 480ms the results feel almost instant but a continuous typist can trigger several searches per word; at 900ms fewer searches fire while typing, but the delay after the last keystroke reads as more noticeable
Toast stack interval Item 01's STACK_STEP_MS constant from 900ms to 1500ms At 900ms a burst of toasts clears itself quickly; at 1500ms more toasts stay visible on screen at once, which reads as busier but gives a visitor more time to read each one
Tab underline duration Item 03's $duration from $dur-enter (600ms) to $dur-loop (2s) At 600ms the underline keeps pace even with fast repeated tab presses; at 2s it visibly lags behind a fast switcher, though a single slow switch reads as more deliberate

This zip ships the 480ms, 900ms, and 600ms values.

Why reduced motion and keyboard or screen-reader users still land on the same computed values

All nine check prefers-reduced-motion and stop their own autoplay loop under it, leaving only the real listeners active — a reduced-motion visitor who clicks the toast button, opens the dialog, or types into the search box still gets the same recomputed values, just without the demo's self-playing preview. Keyboard support is native rather than bolted on: 02's dialog wraps Tab and Shift+Tab inside its own focus range and closes on Escape, 03's tabs use role="tab" with aria-selected and a roving tabindex so arrow keys move selection without leaving extra stops in the page's tab order, and 06's shortcuts announce their result through aria-live="polite" on the same element a sighted visitor watches flash. 01's toast list is also aria-live="polite", so a screen reader hears each new message once, not once per re-render as the stack reindexes.

FAQ

Do any of these nine interactions call a real notification, search, or clipboard service?

No — every message in the toast queue, every result in the debounce search, and the "success" state in the clipboard demo are computed and displayed locally; nothing calls navigator.clipboard, a real search API, or a push-notification service anywhere in the set.

Are these micro interactions examples only, or can the code be used directly?

They're built to be used directly rather than kept as micro interactions inspiration alone — each of the nine is 25 lines or fewer of the core computation shown above and uses no external library.

Which of the nine actually restart a value instead of just repeating it?

Three do: 07 restarts its wait-time countdown from zero on every keystroke rather than extending it, 01 recomputes every surviving toast's stack-order value from scratch each time one is removed, and 08 recomputes the knob's travel distance from a fresh measurement on every toggle rather than reusing a cached number.

Sources: MDN — IntersectionObserver · MDN — Window.localStorage · MDN — ARIA tab role · MDN — Clipboard API

Enter the archive password

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