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

Autocomplete Input: 9 Search Dropdowns

Autocomplete input, explained: Bolds where letters sit in each suggestion, debounces requests, cycles the list by keyboard.

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

Nine builds follow. Five of them — highlighting, debounce, keyboard cycling, typo suggest, and reduced motion — keep the same three-layer shape underneath: the input, the ul[role=listbox], and the selection state, all wrapped in one role="combobox" container. The other four swap that live text input for a list that opens on its own — a chip row, a scrolling group, a ranked table, a bottom sheet — while still building on ul[role=listbox] rows wherever the pattern allows. On top of whichever shape an item uses sits one computed layer: substring highlighting, a debounce ticket, Levenshtein edit distance, mod-wrap keyboard cycling, or a ratio-to-scale bar. The order below moves from the layer closest to what you type (highlighting a match) to the ones that touch scrolling, storage, and viewport geometry, so each item reuses a pattern the one before it already set up.

Demo

All nine dropdowns replay a fixed typing sequence off a performance.now() clock rather than Math.random() or a CSS @keyframes loop, so the same letters land on screen in the same order every time the page loads. A real keystroke — keydown, pointerdown, or beforeinput depending on the item — stops that replay immediately and hands control back to whoever is actually typing, which is the same switch every item below relies on to stay honest about what's scripted and what's live.

Anatomy of autocomplete — the input, list and selection layers, and the aria-combobox structure

The three roles you must not collapse

A working autocomplete is three elements, not one div with a click handler: the outer wrapper carries role="combobox", aria-expanded, and aria-haspopup="listbox"; the native input inside it keeps role="searchbox" and aria-autocomplete="list"; and the suggestions live in a plain ul[role="listbox"] of li[role="option"] rows. Collapsing any of those three into a styled <div> is what silently drops keyboard support and screen-reader announcements, since assistive tech is reading the roles, not the CSS.

Where the selection state actually lives

Item 03 is the one built entirely around that state, and it makes the point for the rest of this post: "which row is active" is never just a CSS :hover — it's a JavaScript index written onto aria-activedescendant on the input itself, so a screen reader announces the highlighted row without focus ever leaving the text field. Every keyboard, pointer, and autoplay path inside that item writes to the same index, which is what its wraparound math depends on.

Code per search box in nine steps

Paste any one item's prompt from the zip into an AI coding tool and it rebuilds the same screen — the block under each item below is trimmed straight out of that item's index.html, the same file that ships inside the password-protected download.

01Match highlight — locating the typed letters inside each suggestion and bolding that span

An autocomplete that only bolds the first occurrence of a query breaks the moment a word repeats a letter pair, so this one keeps searching until indexOf runs out of string.

Why the loop keeps calling indexOf

Stopping after one String.indexOf call would miss every match after the first, so the loop below re-enters at idx + query.length and only gives up once indexOf returns -1 for the rest of the string.

Match highlight — locating the typed letters inside each suggestion and bolding that span
Open in a new tab

01 · Match highlight — locating the typed letters inside each suggestion and bolding that span — Measured area 15.731% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

beforeinputCSS custom propertiesaria-expanded

What the numbers say

Painted area comes out at 15.73% (peak frame 15.64%, average intensity 177.2), moving on 6 of its 23 frame-to-frame comparisons — a low number that's expected here, since the autoplay sequence for this item spends real segments sitting on an empty query between typed words.

function highlightAll(text, query) {
  if (!query) return escapeHtml(text);
  var lowerText = text.toLowerCase();
  var lowerQuery = query.toLowerCase();
  var out = '', i = 0;
  while (i < text.length) {
    var idx = lowerText.indexOf(lowerQuery, i);
    if (idx === -1) { out += escapeHtml(text.slice(i)); break; }
    out += escapeHtml(text.slice(i, idx));
    out += '<mark class="hl__mark">' + escapeHtml(text.slice(idx, idx + query.length)) + '</mark>';
    i = idx + query.length;
  }
  return out;
}

02Debounce shimmer — gathering input for 300ms while the list shimmers as a waiting state

Recomputing a search on every single keystroke wastes calls the moment someone types fast, so this item resets a DEBOUNCE_MS timer on each key and only fires once typing pauses.

The ticket that outruns a late response

Resetting the timer alone isn't enough, because a slow response can still land after a newer one — so every fire stamps an incrementing pendingId, and a response gets discarded outright the instant its own ticket no longer matches the latest one.

Debounce shimmer — gathering input for 300ms while the list shimmers as a waiting state
Open in a new tab

02 · Debounce shimmer — gathering input for 300ms while the list shimmers as a waiting state — Measured area 22.247% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

setTimeoutrequestAnimationFrametransition

What the numbers say

Painted area is 22.25% (peak frame 22.22%), the calmest average intensity of the nine at 33.3, and it moves on 22 of 23 comparisons as the shimmer sweep runs the whole time the timer is waiting.

function onChange(value) {
    clearTimeout(debounceTimer);
    pendingId++;
    var myId = pendingId;
    if (!value) {
      state = 'idle';
      renderIdle();
      return;
    }
    state = 'loading';
    renderSkeleton();
    debounceTimer = setTimeout(function () {
      setTimeout(function () {
        if (myId !== pendingId) return;
        var q = value.toLowerCase();
        results = WORDS.filter(function (w) { return w.toLowerCase().indexOf(q) !== -1; });
        state = 'done';
        renderResults(value);
      }, FAKE_LATENCY_MS);
    }, DEBOUNCE_MS);
  }

03Keyboard cycling — picking items with arrows and wrapping to the far end via mod arithmetic

Clamping the active row with Math.min/Math.max leaves keyboard users stuck at the edges, so this item wraps the index back around instead of stopping there.

One index, three things updated at once

activeIndex is the only number that moves, and updating it in one place keeps the highlighted class, the aria-activedescendant id, and the scrollIntoView call from ever drifting out of sync with each other.

Keyboard cycling — picking items with arrows and wrapping to the far end via mod arithmetic
Open in a new tab

03 · Keyboard cycling — picking items with arrows and wrapping to the far end via mod arithmetic — Measured area 35.733% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownaria-activedescendantscrollIntoView

What the numbers say

This is the item most in motion — painted area 35.73% (peak frame 15.11%), average intensity 94.6, moving on all 23 of 23 comparisons as the autoplay's STEP_MS timer keeps stepping the active row forward.

function updateActive() {
    for (var i = 0; i < items.length; i++) items[i].classList.remove('is-active');
    var el = items[activeIndex];
    if (!el) return;
    el.classList.add('is-active');
    input.setAttribute('aria-activedescendant', el.id);
    el.scrollIntoView({ block: 'nearest' });
  }

  function moveActive(delta) {
    activeIndex = (activeIndex + delta + WORDS.length) % WORDS.length;
    updateActive();
  }

04Recent-search chips — recomputing stored timestamps into '3 min ago, 2 days ago' labels

A relative-time label saved once at write time goes stale the second the clock keeps running, so this item's chips look up their raw timestamp fresh every time the list opens.

Minutes, hours, and days, not a library

Dividing now - ts by the named MIN, HOUR, and DAY constants in order — smallest bucket first — is the entire relative-time engine here, and it's cheap enough to rerun on every render instead of caching a formatted string.

Recent-search chips — recomputing stored timestamps into '3 min ago, 2 days ago' labels
Open in a new tab

04 · Recent-search chips — recomputing stored timestamps into '3 min ago, 2 days ago' labels — Measured area 7.149% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

localStoragetransform: scaleCSS custom properties

What the numbers say

Painted area sits at 7.15% (peak frame 5.85%, average intensity 59.8) and moves on only 7 of 23 comparisons, which tracks with the autoplay here bumping and deleting a couple of chips rather than repainting the whole row on every frame.

function label(delta) {
  if (delta < MIN) return 'Just now';
  if (delta < HOUR) return Math.floor(delta / MIN) + ' min ago';
  if (delta < DAY) return Math.floor(delta / HOUR) + ' hr ago';
  return Math.floor(delta / DAY) + ' day ago';
}

05Grouped sticky headers — splitting the list into groups whose headers pin on scroll

position: sticky alone pins a header in place, but it doesn't tell you how close the next group is to shoving it out, so this item adds a measurement pass on top of the CSS.

Turning scroll position into a shadow

Every scroll event re-measures the gap between the current header and the next one with getBoundingClientRect(), turns that gap into a ratio between 0 and 1, and feeds the same ratio into both the header's box-shadow alpha and its bottom-border alpha.

Grouped sticky headers — splitting the list into groups whose headers pin on scroll
Open in a new tab

05 · Grouped sticky headers — splitting the list into groups whose headers pin on scroll — Measured area 34.696% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

position: stickyscrollCSS custom properties

What the numbers say

Painted area reaches 34.70% (peak frame 10.13%, average intensity 59.6), moving on all 23 of 23 comparisons because the demo's autoplay scrolls the list itself instead of waiting for a pointer.

function applyShadow(i, ratio) {
    var head = heads[i];
    if (!head) return;
    var alpha = 0.10 + 0.28 * ratio;
    head.style.boxShadow = '0 6px 10px rgba(23,20,26,' + alpha.toFixed(3) + ')';
    head.style.borderBottomColor = 'rgba(23,20,26,' + (0.08 + 0.4 * ratio).toFixed(3) + ')';
  }

  function measureStuck() {
    var listTop = list.getBoundingClientRect().top;
    for (var i = 0; i < heads.length; i++) {
      var next = heads[i + 1];
      if (!next) { applyShadow(i, 0); continue; }
      var headH = heads[i].offsetHeight;
      var nextTop = next.getBoundingClientRect().top - listTop;
      var ratio = 1 - Math.min(1, Math.max(0, (nextTop - headH) / headH));
      ratio = Math.min(1, Math.max(0, ratio));
      applyShadow(i, ratio);
    }
  }

06Typo suggest — computing edit distance to float a 'did you mean?' row

Comparing raw, unnormalized strings lets a stray capital letter or trailing space inflate a typo's distance for no real reason, so both sides get normalized before anything is measured.

The DP table underneath 'did you mean'

Levenshtein distance here is a plain 2D dp[i][j] table — the minimum of an insert, a delete, and a substitute at each cell — and a "did you mean?" row only appears when the closest candidate lands between 1 and the named THRESHOLD, never above it.

Typo suggest — computing edit distance to float a 'did you mean?' row
Open in a new tab

06 · Typo suggest — computing edit distance to float a 'did you mean?' row — Measured area 45.977% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

beforeinputCSS custom propertiesaria-expanded

What the numbers say

Painted area is the widest of the nine at 45.98% (peak frame 42.20%, average intensity 89.8), but it moves on only 7 of 23 comparisons since the sequence here holds on a full typo string for most of its playback before the DP table has anything new to resolve.

function editDistance(a, b) {
    var m = a.length, n = b.length;
    var dp = [];
    for (var i = 0; i <= m; i++) { dp.push([i]); }
    for (var j = 0; j <= n; j++) { dp[0][j] = j; }
    for (i = 1; i <= m; i++) {
      for (j = 1; j <= n; j++) {
        if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
        else {
          dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
        }
      }
    }
    return dp[m][n];
  }

07Trending bars — scaling search counts against the max into ranked bars

Hardcoding a bar's pixel width means remeasuring by hand every time the underlying counts change, so this item stores a ratio instead of a width.

One ratio, read by both the bar and the arrow

Each row divides its own count by the list's max into a 0-to-1 ratio, writes it to a --ratio custom property that transform: scaleX() reads directly, while a separate deltaIcon() compares prevRank to the freshly sorted currRank to decide which arrow to draw.

Trending bars — scaling search counts against the max into ranked bars
Open in a new tab

07 · Trending bars — scaling search counts against the max into ranked bars — Measured area 4.804% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform: scaleXCSS custom propertiestransition

What the numbers say

This is the smallest footprint of the nine — painted area 4.80% (peak frame 0.77%) — but also the brightest average intensity at 184.9, moving on 12 of 23 comparisons as the entrance stagger fills each bar in rank order.

function render() {
    var rows = ranked.map(function (d, i) {
      var currRank = i + 1;
      var ratio = d.count / max;
      return '<li class="tr__row" role="option" style="--i:' + i + '">' +
        '<span class="tr__rank">' + currRank + '</span>' +
        '<span class="tr__q">' + d.q + '</span>' +
        '<span class="tr__delta">' + deltaIcon(d.prevRank, currRank) + '</span>' +
        '<span class="tr__track"><span class="tr__bar" style="--ratio:' + ratio.toFixed(3) + '"></span></span>' +
        '<span class="tr__count">' + d.count + '</span>' +
        '</li>';
    });
    list.innerHTML = rows.join('');
  }

08Mobile sheet morph — the dropdown reshaping into a bottom sheet under a media query

A dropdown that stays a fixed-position box on a 340px-wide screen ends up half off-canvas, so this item swaps the same list markup into a bottom-sheet layout under a width query instead of shipping two separate components.

A lerp standing in for a keyboard that isn't there

window.visualViewport can report exactly how many pixels a real virtual keyboard has eaten, but that number is zero during an automated capture — so a current/target pair is nudged toward each other by 12% every frame and written to a --kb custom property the sheet's height reads through calc().

Mobile sheet morph — the dropdown reshaping into a bottom sheet under a media query
Open in a new tab

08 · Mobile sheet morph — the dropdown reshaping into a bottom sheet under a media query — Measured area 29.61% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

visualViewporttransitionmatchMedia

What the numbers say

Painted area lands at 29.61% (peak frame 16.47%, average intensity 66.5), moving on 13 of 23 comparisons as the sheet's height cycles open and closed on its own timer.

function tick(now) {
    if (now - lastFlip > 900) {
      target = target === 0 ? 1 : 0;
      lastFlip = now;
    }
    current += (target - current) * 0.12;
    sheet.style.setProperty('--kb', current.toFixed(4));
    if (playing) requestAnimationFrame(tick);
  }

09Reduced motion — killing shimmer and stagger for a list that appears instantly

Hiding a whole feature for prefers-reduced-motion users is the wrong read of that setting, so this item only zeroes the timing that makes rows appear one after another.

The delay is the only thing that changes

Each row's entrance delay is index * STEP_MS, written onto --delay; flipping reduced to true just sets STEP_MS to 0 in that same formula, so the identical list of rows appears in the identical order, only without the stagger between them.

Reduced motion — killing shimmer and stagger for a list that appears instantly
Open in a new tab

09 · Reduced motion — killing shimmer and stagger for a list that appears instantly — Measured area 30.961% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

prefers-reduced-motiontransitionaria-expanded

What the numbers say

Painted area is 30.96% (peak frame 27.44%, average intensity 47.1), moving on 20 of 23 comparisons as the on-screen switch flips reduced back and forth on its own timer to demonstrate both states.

function open() {
    var step = reduced ? 0 : STEP_MS;
    list.innerHTML = WORDS.map(function (w, i) {
      return '<li class="rm__item" role="option" style="--delay:' + (i * step) + 'ms">' + w + '</li>';
    }).join('');
    input.setAttribute('aria-expanded', 'true');
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        list.classList.add('is-open');
      });
    });
  }

The list is math — substring match, edit distance, and ranking score formulas

Substring, edit distance, and ratio — three cheap formulas

Every item that reshapes the list above reduces to one of three small computations run fresh each time: a repeated indexOf scan for highlighting, a 2D edit-distance table for typo suggestions, or a value / max ratio for anything that draws a bar. None of the three needs more than a handful of variables, which is exactly why an autocomplete search dropdown can afford to rerun them on every keystroke instead of caching a precomputed answer.

Why none of this gets cached once and reused

A cached highlight position, a cached edit distance, or a cached ratio is only correct for the data it was computed against, and an autocomplete input field's data — what's typed, what's stored, what's currently ranked — changes on nearly every interaction; a slider like the one that writes a single --pct value into a custom property every frame instead of computing it once makes the same trade for the same reason. The moment any of these nine cached a result instead of recomputing it, a second keystroke or a second scroll would start showing a stale answer without any visible error to flag it.

Where these nine actually sit

A site-wide search bar in the header, a product search box on a storefront, and the document search on a help center are the three spots that ask for autocomplete most. Item 01's match highlight fits a product search where visitors need to see exactly where their letters landed, and item 04's recent-search chips fit a help center where people search the same terms again and again.

For a search box where trending terms shift constantly, item 07's trending bar ranks what's spiking right now. All three screens share the same requirement: the list has to recompute on every keystroke, so a structure that bundles debouncing with the ranking math fits them without extra work.

Where it breaks — mobile keyboards, IME, and scroll traps

Mobile keyboards and the visualViewport gap

Item 08's own source comment is blunt about this: realKbPx() reads window.visualViewport correctly on a real phone, but that value stays at 0 during an automated screenshot capture, since no virtual keyboard actually opens — which is why the visible sheet motion in this post's demo comes from the scripted current/target lerp standing in for a keyboard height that isn't really there. Anyone wiring this into a real mobile form should expect the opposite split on a device: visualViewport will report a real number the instant the keyboard opens, and the lerp becomes decoration on top of that real value rather than a substitute for it.

IME composition and IME-safe listeners

Item 06 attaches its autoplay-stopping listener to beforeinput rather than keydown, which matters the moment a query is typed through an IME — a composed character can fire several beforeinput and input events before the syllable is actually finished, and a naive keydown-only listener like item 01's would miss the composition entirely. The debounce in item 02 sidesteps the same trap for a different reason: resetting its timer on input rather than trying to guess when a keystroke is "done" means a mid-composition burst just keeps pushing the timer forward instead of firing early on an incomplete word — the fix an autocomplete search dropdown that only listens for keyup would still need to make. The full nine-item source, both plain and React, sits behind 8gshpuef in the zip linked below, the same way the entrance-timing math in the roulette wheel post ships behind its own password rather than as a live-editable snippet.

Slow the shimmer and the cycle, and accessibility gets sorted out with it

Variant What changes What it trades off
Debounce wait time Item 02's DEBOUNCE_MS from 300 to 500 300ms reacts the instant fast typing pauses; 500ms cuts requests by more than half and only refreshes the list once typing has genuinely settled
Typo-match distance Item 06's THRESHOLD from 2 to 1 2 lets "aple" match both "apple" and "maple"; narrowing to 1 leaves only the single most confident correction, so suggestions turn conservative
Maximum visible rows Item 01's WORDS list from 8 to 12 8 fits on screen at a glance; 12 introduces scrolling and brings item 05's header overlap and item 07's ranked bars fully to life, since a short list never gives either computation anything to show

Reduced motion, item by item

prefers-reduced-motion: reduce turns off the shimmer, the stagger, and the autoplay loop in every one of the nine items without touching what each one actually computes: item 01 lands directly on its target query instead of stepping through the typing sequence, item 03 sets STEP_MS to 0 so the arrow-key wraparound math still runs on a real press, item 05 stops the scripted scroll but leaves measureStuck() wired to every real scroll event, and item 09 — built entirely around this switch — simply removes the index * STEP_MS delay from its own formula. A reduced-motion visitor still gets substring highlighting, the debounce ticket, the edit-distance table, and the ratio-to-scale bars; the only thing that disappears is the time each one takes to arrive.

Three questions that keep coming up about this search box

Does an autocomplete input field need a component library to work like this?

No — all nine items are vanilla HTML, CSS, and JavaScript with no framework and no CDN, and the full source for every one, plus matching React versions, ships inside the password-protected zip linked from this post.

Why does the keyboard-cycling item wrap instead of stopping at the last row?

Because activeIndex = (activeIndex + delta + WORDS.length) % WORDS.length never clamps — pressing the down arrow past the last suggestion returns to the first one in the same motion a real search box's users expect, and aria-activedescendant updates in that same frame so a screen reader announces the wrap too.

Can a javascript autocomplete search dropdown stay accessible with the mouse unplugged?

It depends which of the nine you mean. Items 01 and 02 take a real typed query with nothing decorative standing in for the input, and item 03 pairs a real keydown handler with aria-activedescendant so a screen reader announces the active row while scrollIntoView({block:'nearest'}) keeps it in view — a keyboard user never has to touch a pointer there. The rest of this post's items swap that live interaction for a fixed demo state so the autoplay can show off the computed layer on its own; wiring one into a real search box just means adding back the same focus and keydown handling item 03 already demonstrates.

Sources: MDN — ARIA combobox role · MDN — aria-activedescendant · MDN — prefers-reduced-motion · MDN — beforeinput event

Enter the archive password

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