# Combobox

> A real select you can type into: the list filters as you type, accents and case aside, the matched letters underlined and the best match lit for Enter. Codes on the right are searched too.

Forms · combobox, autocomplete, select, form, keyboard · HTML, CSS, vanilla JS · no dependencies · updated 2026-11-04
Demo: https://kodza.dev/components/combobox/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/combobox

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-combobox.css`](https://kodza.dev/registry/combobox/kodza-combobox.css) · 29.0 KB
- [`kodza-combobox.js`](https://kodza.dev/registry/combobox/kodza-combobox.js) · 27.5 KB
- [`kodza-combobox.html`](https://kodza.dev/registry/combobox/kodza-combobox.html) · 7.8 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS and the JS, don't retype them.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-combobox.css https://kodza.dev/registry/combobox/kodza-combobox.css
curl -fsSL --create-dirs -o kodza/kodza-combobox.js https://kodza.dev/registry/combobox/kodza-combobox.js
```

Then load the files from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS and the JS from the Code section only if you can't run commands, exactly as they are. (Windows PowerShell: `curl.exe`.)

## API

- Root: `.kodza-combobox`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-combobox-bg: #ffffff`
  - `--kz-combobox-fg: #0a0a0a`
  - `--kz-combobox-muted: rgba(10, 10, 10, 0.6)`
  - `--kz-combobox-line: rgba(10, 10, 10, 0.14)`
  - `--kz-combobox-line-hover: rgba(10, 10, 10, 0.3)`
  - `--kz-combobox-soft: rgba(10, 10, 10, 0.06)`
  - `--kz-combobox-selection: rgba(128, 128, 128, 0.35)` — the selected text: grey, not the browser blue
  - `--kz-combobox-error: #0a0a0a` — a message, never colour alone: set a red here if you want one
  - `--kz-combobox-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3)`
  - `--kz-combobox-height: 56px`
  - `--kz-combobox-radius: calc(12px * var(--kz-radius-scale, 1))`
  - `--kz-combobox-pad: 16px`
  - `--kz-combobox-width: var(--kz-field-width, 320px)`
  - `--kz-combobox-list-height: 280px` — the open list, at most: it scrolls past that
  - `--kz-combobox-duration: 0.35s`
  - `--kz-combobox-ease: cubic-bezier(0.22, 1, 0.36, 1)`
- Attributes:
  - `data-meta` (e.g. `AR`, `AU`, `AT`, `BE`, `BR`, `CA`)
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Keyboard

What each key does, by where the focus is. Keep it working when you adapt the HTML: same elements, same classes.

- In the field, while it's open (focus in `.kodza-combobox__input`, while `.kodza-combobox.is-open` is on the page):
  - `ArrowDown` / `ArrowUp`: Next or previous option in the list, no looping
  - `PageDown` / `PageUp`: Five options down or up
  - `Enter`: Picks the highlighted option and closes
  - `Tab`: Picks the highlighted option and moves on
  - `Escape`: Closes it and puts the last choice back
- In the field (focus in `.kodza-combobox__input`):
  - a letter: Filters the list, best match first and lit
  - `ArrowDown` / `ArrowUp`: Opens the whole list on the current choice
  - `Backspace`: Edits the search; leaving it empty clears the choice

## Dark mode

The values Kodza uses on a dark background (`#0a0a0a`). For a page that follows the system theme, add them after the component's CSS:

```css
@media (prefers-color-scheme: dark) {
  .kodza-combobox {
    --kz-combobox-bg: #161616;
    --kz-combobox-fg: #f2f2f0;
    --kz-combobox-muted: rgba(242, 242, 240, 0.62);
    --kz-combobox-line: rgba(255, 255, 255, 0.14);
    --kz-combobox-line-hover: rgba(255, 255, 255, 0.3);
    --kz-combobox-soft: rgba(255, 255, 255, 0.08);
    --kz-combobox-error: #f2f2f0;
    --kz-combobox-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-combobox { … }`); a single dark section, under that section's class.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-combobox.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-combobox.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- JS: `kodza/kodza-combobox.js`, loaded with `<script src="kodza/kodza-combobox.js" defer></script>` in `<head>`. Without a shell, paste it in a `<script>` before `</body>` (or in `<head>`: it waits for the page). One copy per page, whatever the number of instances.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters). The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza).
- Width: a form sets it once for every Kodza field, on the form or on `:root` (`--kz-field-width: 100%` fills the column). Unset, nothing changes.
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Content added later (AJAX navigation, a SPA route, a builder preview): start it with `document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: container } }))`; before removing it, the same call with `'kodza:destroy'` stops it and puts the original markup back.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>` and the JS in a `<script>` at the end, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, `KODZA-JS` inside the `<script>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-combobox.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-combobox.js' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters).
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza).
- Width: a form sets it once for every Kodza field, on the form or on `:root` (`--kz-field-width: 100%` fills the column). Unset, nothing changes.
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.
- Forms: there is nowhere to send them. Handle `submit` in your script with `event.preventDefault()` and show the result in the page.

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- CSS and JS: the downloaded files go in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-combobox', $uri . 'kodza-combobox.css', [], filemtime($dir . 'kodza-combobox.css'));
    wp_enqueue_script('kodza-combobox', $uri . 'kodza-combobox.js', [], filemtime($dir . 'kodza-combobox.js'), true);
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme. One copy of the JS, whatever the number of blocks.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- Theme with AJAX page transitions: fire `kodza:init` on the new content after each swap (and `kodza:destroy` on the old one).
- Performance plugin (WP Rocket, LiteSpeed Cache, Perfmatters…) with **Delay JavaScript execution** turned on: add `kodza` to the scripts it must not delay. Otherwise the component waits for the visitor's first scroll, tap or mouse move, and that first click can be lost.
- Colours: by default, match the theme's palette. After the component's CSS, point these variables at the theme's colours, on the component root, Kodza's value as the fallback:
  `.kodza-combobox { --kz-combobox-fg: var(TEXT, #0a0a0a); --kz-combobox-bg: var(BACKGROUND, #ffffff); }`
  TEXT, BACKGROUND and ACCENT (the colour the theme fills its own buttons with) are the theme's own colour variables — block themes (theme.json, like Twenty Twenty-Five): `--wp--preset--color--contrast`, `--wp--preset--color--base`, `--wp--preset--color--contrast` (they fill their buttons with it; their accent-1… colours are decorative); Astra: `--ast-global-color-2`, `--ast-global-color-4`, `--ast-global-color-0`; Kadence: `--global-palette3`, `--global-palette9`, `--global-palette1`; GeneratePress: `--contrast`, `--base-3`, `--accent`; Blocksy: `--theme-palette-color-4`, `--theme-palette-color-8`, `--theme-palette-color-1`. Use only these names, and only if the site really has them (the theme's CSS, or Styles → Colors): the `--wp--preset--color--black`, `--white`, `--vivid-…` that WordPress adds everywhere are not the theme's palette. Another theme, or one without these variables (Hello Elementor, OceanWP): leave the variables alone, it keeps Kodza's monochrome; same when the developer asks for monochrome or the Kodza look. Leave the translucent greys (`rgba(10, 10, 10, …)`) as they are: they suit any light background. On a dark palette, set the other variables from Dark mode too.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- CSS and JS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- The Elementor editor redraws each widget after the page has loaded: without the script below, the component stays still in the editor preview (the published page works). Add it once per site, in the footer like the component's script (`kodza/elementor-editor.js`, or a code-snippets plugin):

```js
// Kodza in the Elementor editor: the editor redraws each HTML widget after the page has loaded.
// Start Kodza in what it draws, stop it in what it replaces or removes. Once per site.
window.addEventListener('elementor/frontend/init', () => {
  if (!elementorFrontend.isEditMode()) return;
  class KodzaWidget extends elementorModules.frontend.handlers.Base {
    onInit() {
      super.onInit();
      this.kodza = [...this.$element[0].children];
      document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: this.$element[0] } }));
    }
    onDestroy() {
      super.onDestroy();
      this.kodza.forEach((root) => document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } })));
    }
  }
  elementorFrontend.elementsHandler.attachHandler('html', KodzaWidget);
});
```

- No child theme: everything can go in the HTML widget instead, `<style>` with the CSS, the HTML, then `<script>` with the JS. It works on the page and in the editor without the script above.

### Shopify

- Files: `assets/kodza-combobox.css` and `assets/kodza-combobox.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-combobox.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-combobox.js' | asset_url }}" defer></script>`, then the HTML.
- The theme editor re-renders sections: restart them with
  `document.addEventListener('shopify:section:load', (e) => document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: e.target } })));`
  and the same with `shopify:section:unload` → `kodza:destroy`.

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `Combobox.tsx` (below), the CSS block under Code saved as `combobox.css` and the JS block as `combobox.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { Combobox } from '@/components/kodza/Combobox';` then `<Combobox />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-combobox-bg': … }}`), `className`, `style` and the other props of its `<div>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './combobox.css'` line there.
- Safe under StrictMode, client-side navigation and prop changes: when a prop changes, the `key` gives React a fresh element, `kodza:destroy` stops the script on the old one and `kodza:init` starts it on the new one.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: follow it with `onClick` or `onInput` on the root, and change it from your code as a visitor would (click the tab; set the field's value, then dispatch a bubbling `input` event). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'use client';
// Kodza — Combobox, for React. From https://kodza.dev/components/combobox/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: combobox.css and combobox.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './combobox.css';

export type ComboboxProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #ffffff */
    "--kz-combobox-bg"?: string | number;
    /** default: #0a0a0a */
    "--kz-combobox-fg"?: string | number;
    /** default: rgba(10, 10, 10, 0.6) */
    "--kz-combobox-muted"?: string | number;
    /** default: rgba(10, 10, 10, 0.14) */
    "--kz-combobox-line"?: string | number;
    /** default: rgba(10, 10, 10, 0.3) */
    "--kz-combobox-line-hover"?: string | number;
    /** default: rgba(10, 10, 10, 0.06) */
    "--kz-combobox-soft"?: string | number;
    /** the selected text: grey, not the browser blue — default: rgba(128, 128, 128, 0.35) */
    "--kz-combobox-selection"?: string | number;
    /** a message, never colour alone: set a red here if you want one — default: #0a0a0a */
    "--kz-combobox-error"?: string | number;
    /** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
    "--kz-combobox-shadow"?: string | number;
    /** default: 56px */
    "--kz-combobox-height"?: string | number;
    /** default: calc(12px * var(--kz-radius-scale, 1)) */
    "--kz-combobox-radius"?: string | number;
    /** default: 16px */
    "--kz-combobox-pad"?: string | number;
    /** default: var(--kz-field-width, 320px) */
    "--kz-combobox-width"?: string | number;
    /** the open list, at most: it scrolls past that — default: 280px */
    "--kz-combobox-list-height"?: string | number;
    /** default: 0.35s */
    "--kz-combobox-duration"?: string | number;
    /** default: cubic-bezier(0.22, 1, 0.36, 1) */
    "--kz-combobox-ease"?: string | number;
  };
  className?: string;
};

export function Combobox({ vars, className, style, ...rest }: ComboboxProps) {
  const ref = useRef<ComponentRef<'div'>>(null);
  const classes = ["kodza-combobox", className].filter(Boolean).join(' ');
  // Everything the markup is made of: when it changes, React gets a fresh element (see the top)
  const key = JSON.stringify([classes, vars, style, rest]);

  // Starts the script on this element once React has rendered it, stops it when it goes
  useEffect(() => {
    const root = ref.current;
    let live = true;
    // @ts-ignore: a plain script, no types to find
    import('./combobox.js').then(() => {
      if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
    });
    return () => {
      live = false;
      document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
    };
  }, [key]);

  return (
    <div key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <label className="kodza-combobox__box">
        <span className="kodza-combobox__label">Country</span>
        <select className="kodza-combobox__native" name="country" required defaultValue="">
          <option value="" hidden />
          <option value="ar" data-meta="AR">Argentina</option>
          <option value="au" data-meta="AU">Australia</option>
          <option value="at" data-meta="AT">Austria</option>
          <option value="be" data-meta="BE">Belgium</option>
          <option value="br" data-meta="BR">Brazil</option>
          <option value="ca" data-meta="CA">Canada</option>
          <option value="cl" data-meta="CL">Chile</option>
          <option value="cn" data-meta="CN">China</option>
          <option value="co" data-meta="CO">Colombia</option>
          <option value="ci" data-meta="CI">Côte d’Ivoire</option>
          <option value="cz" data-meta="CZ">Czechia</option>
          <option value="dk" data-meta="DK">Denmark</option>
          <option value="eg" data-meta="EG">Egypt</option>
          <option value="ee" data-meta="EE">Estonia</option>
          <option value="fi" data-meta="FI">Finland</option>
          <option value="fr" data-meta="FR">France</option>
          <option value="de" data-meta="DE">Germany</option>
          <option value="gh" data-meta="GH">Ghana</option>
          <option value="gr" data-meta="GR">Greece</option>
          <option value="is" data-meta="IS">Iceland</option>
          <option value="in" data-meta="IN">India</option>
          <option value="id" data-meta="ID">Indonesia</option>
          <option value="ie" data-meta="IE">Ireland</option>
          <option value="it" data-meta="IT">Italy</option>
          <option value="jp" data-meta="JP">Japan</option>
          <option value="ke" data-meta="KE">Kenya</option>
          <option value="mx" data-meta="MX">Mexico</option>
          <option value="ma" data-meta="MA">Morocco</option>
          <option value="nl" data-meta="NL">Netherlands</option>
          <option value="nz" data-meta="NZ">New Zealand</option>
          <option value="ng" data-meta="NG">Nigeria</option>
          <option value="no" data-meta="NO">Norway</option>
          <option value="pe" data-meta="PE">Peru</option>
          <option value="pl" data-meta="PL">Poland</option>
          <option value="pt" data-meta="PT">Portugal</option>
          <option value="re" data-meta="RE">Réunion</option>
          <option value="sn" data-meta="SN">Senegal</option>
          <option value="sg" data-meta="SG">Singapore</option>
          <option value="za" data-meta="ZA">South Africa</option>
          <option value="kr" data-meta="KR">South Korea</option>
          <option value="es" data-meta="ES">Spain</option>
          <option value="se" data-meta="SE">Sweden</option>
          <option value="ch" data-meta="CH">Switzerland</option>
          <option value="tr" data-meta="TR">Türkiye</option>
          <option value="gb" data-meta="GB">United Kingdom</option>
          <option value="us" data-meta="US">United States</option>
          <option value="uy" data-meta="UY">Uruguay</option>
          <option value="vn" data-meta="VN">Vietnam</option>
        </select>
      </label>
      <p className="kodza-combobox__note">
        <span className="kodza-combobox__hint">Accents optional. Try “cote”.</span>
        {' '}
        <span className="kodza-combobox__error" aria-live="polite" />
      </p>
    </div>
  );
}
```

## Code

```html
<div class="kodza-combobox">
  <label class="kodza-combobox__box">
    <span class="kodza-combobox__label">Country</span>
    <select class="kodza-combobox__native" name="country" required>
      <option value="" selected hidden></option>
      <option value="ar" data-meta="AR">Argentina</option>
      <option value="au" data-meta="AU">Australia</option>
      <option value="at" data-meta="AT">Austria</option>
      <option value="be" data-meta="BE">Belgium</option>
      <option value="br" data-meta="BR">Brazil</option>
      <option value="ca" data-meta="CA">Canada</option>
      <option value="cl" data-meta="CL">Chile</option>
      <option value="cn" data-meta="CN">China</option>
      <option value="co" data-meta="CO">Colombia</option>
      <option value="ci" data-meta="CI">Côte d’Ivoire</option>
      <option value="cz" data-meta="CZ">Czechia</option>
      <option value="dk" data-meta="DK">Denmark</option>
      <option value="eg" data-meta="EG">Egypt</option>
      <option value="ee" data-meta="EE">Estonia</option>
      <option value="fi" data-meta="FI">Finland</option>
      <option value="fr" data-meta="FR">France</option>
      <option value="de" data-meta="DE">Germany</option>
      <option value="gh" data-meta="GH">Ghana</option>
      <option value="gr" data-meta="GR">Greece</option>
      <option value="is" data-meta="IS">Iceland</option>
      <option value="in" data-meta="IN">India</option>
      <option value="id" data-meta="ID">Indonesia</option>
      <option value="ie" data-meta="IE">Ireland</option>
      <option value="it" data-meta="IT">Italy</option>
      <option value="jp" data-meta="JP">Japan</option>
      <option value="ke" data-meta="KE">Kenya</option>
      <option value="mx" data-meta="MX">Mexico</option>
      <option value="ma" data-meta="MA">Morocco</option>
      <option value="nl" data-meta="NL">Netherlands</option>
      <option value="nz" data-meta="NZ">New Zealand</option>
      <option value="ng" data-meta="NG">Nigeria</option>
      <option value="no" data-meta="NO">Norway</option>
      <option value="pe" data-meta="PE">Peru</option>
      <option value="pl" data-meta="PL">Poland</option>
      <option value="pt" data-meta="PT">Portugal</option>
      <option value="re" data-meta="RE">Réunion</option>
      <option value="sn" data-meta="SN">Senegal</option>
      <option value="sg" data-meta="SG">Singapore</option>
      <option value="za" data-meta="ZA">South Africa</option>
      <option value="kr" data-meta="KR">South Korea</option>
      <option value="es" data-meta="ES">Spain</option>
      <option value="se" data-meta="SE">Sweden</option>
      <option value="ch" data-meta="CH">Switzerland</option>
      <option value="tr" data-meta="TR">Türkiye</option>
      <option value="gb" data-meta="GB">United Kingdom</option>
      <option value="us" data-meta="US">United States</option>
      <option value="uy" data-meta="UY">Uruguay</option>
      <option value="vn" data-meta="VN">Vietnam</option>
    </select>
  </label>
  <p class="kodza-combobox__note">
    <span class="kodza-combobox__hint">Accents optional. Try “cote”.</span>
    <span class="kodza-combobox__error" aria-live="polite"></span>
  </p>
</div>

<div class="kodza-combobox">
  <label class="kodza-combobox__box">
    <span class="kodza-combobox__label">Time zone</span>
    <select class="kodza-combobox__native" name="timezone">
      <optgroup label="Europe">
        <option value="Europe/London" data-meta="UTC+0">London</option>
        <option value="Europe/Lisbon" data-meta="UTC+0">Lisbon</option>
        <option value="Europe/Paris" data-meta="UTC+1" selected>Paris</option>
        <option value="Europe/Berlin" data-meta="UTC+1">Berlin</option>
        <option value="Europe/Madrid" data-meta="UTC+1">Madrid</option>
        <option value="Europe/Stockholm" data-meta="UTC+1">Stockholm</option>
        <option value="Europe/Athens" data-meta="UTC+2">Athens</option>
        <option value="Europe/Helsinki" data-meta="UTC+2">Helsinki</option>
        <option value="Europe/Istanbul" data-meta="UTC+3">Istanbul</option>
      </optgroup>
      <optgroup label="Africa">
        <option value="Africa/Dakar" data-meta="UTC+0">Dakar</option>
        <option value="Africa/Lagos" data-meta="UTC+1">Lagos</option>
        <option value="Africa/Cairo" data-meta="UTC+2">Cairo</option>
        <option value="Africa/Johannesburg" data-meta="UTC+2">Johannesburg</option>
        <option value="Africa/Nairobi" data-meta="UTC+3">Nairobi</option>
      </optgroup>
      <optgroup label="Americas">
        <option value="America/Anchorage" data-meta="UTC−9">Anchorage</option>
        <option value="America/Los_Angeles" data-meta="UTC−8">Los Angeles</option>
        <option value="America/Denver" data-meta="UTC−7">Denver</option>
        <option value="America/Mexico_City" data-meta="UTC−6">Mexico City</option>
        <option value="America/Chicago" data-meta="UTC−6">Chicago</option>
        <option value="America/New_York" data-meta="UTC−5">New York</option>
        <option value="America/Bogota" data-meta="UTC−5">Bogotá</option>
        <option value="America/Halifax" data-meta="UTC−4">Halifax</option>
        <option value="America/Santiago" data-meta="UTC−3">Santiago</option>
        <option value="America/Sao_Paulo" data-meta="UTC−3">São Paulo</option>
        <option value="America/Argentina/Buenos_Aires" data-meta="UTC−3">Buenos Aires</option>
      </optgroup>
      <optgroup label="Asia">
        <option value="Asia/Dubai" data-meta="UTC+4">Dubai</option>
        <option value="Asia/Karachi" data-meta="UTC+5">Karachi</option>
        <option value="Asia/Kolkata" data-meta="UTC+5:30">Mumbai</option>
        <option value="Asia/Kathmandu" data-meta="UTC+5:45">Kathmandu</option>
        <option value="Asia/Dhaka" data-meta="UTC+6">Dhaka</option>
        <option value="Asia/Bangkok" data-meta="UTC+7">Bangkok</option>
        <option value="Asia/Singapore" data-meta="UTC+8">Singapore</option>
        <option value="Asia/Shanghai" data-meta="UTC+8">Shanghai</option>
        <option value="Asia/Seoul" data-meta="UTC+9">Seoul</option>
        <option value="Asia/Tokyo" data-meta="UTC+9">Tokyo</option>
      </optgroup>
      <optgroup label="Pacific">
        <option value="Pacific/Honolulu" data-meta="UTC−10">Honolulu</option>
        <option value="Australia/Perth" data-meta="UTC+8">Perth</option>
        <option value="Australia/Sydney" data-meta="UTC+11">Sydney</option>
        <option value="Pacific/Fiji" data-meta="UTC+12">Fiji</option>
        <option value="Pacific/Auckland" data-meta="UTC+13">Auckland</option>
      </optgroup>
    </select>
  </label>
  <p class="kodza-combobox__note">
    <span class="kodza-combobox__hint">City or offset: “+9” works.</span>
    <span class="kodza-combobox__error" aria-live="polite"></span>
  </p>
</div>

<div class="kodza-combobox">
  <label class="kodza-combobox__box">
    <span class="kodza-combobox__label">Assign to</span>
    <select class="kodza-combobox__native" name="assignee">
      <option value="" selected>Unassigned</option>
      <option value="amara" data-meta="@amara">Amara Okafor</option>
      <option value="hanna" data-meta="@hanna">Hanna Nowak</option>
      <option value="ines" data-meta="@ines">Inès Moreau</option>
      <option value="kenji" data-meta="@kenji">Kenji Watanabe</option>
      <option value="leo" data-meta="@leo">Léo Lefebvre</option>
      <option value="mateo" data-meta="@mateo">Mateo García</option>
      <option value="ravi" data-meta="@ravi">Ravi Iyer</option>
      <option value="sofia" data-meta="@sofia">Sofia Rossi</option>
      <option value="dependabot" data-meta="@bot" disabled>Dependabot (just bumps)</option>
    </select>
  </label>
  <p class="kodza-combobox__note">
    <span class="kodza-combobox__hint">Name or @handle.</span>
    <span class="kodza-combobox__error" aria-live="polite"></span>
  </p>
</div>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Combobox
   A select you can type into. The script lays a text field over a real select (kept in step,
   so forms and WordPress get the value): typing filters the list, accents and case don't
   matter, the matched letters are underlined and the best match is lit, ready for Enter. Groups
   stay, a short code rides on the right of each option (data-meta, searched too: "+9", "@ines",
   "FR"), and the list changes height smoothly as it filters. Leave the field on a half-typed
   name and it takes the only match, or puts the last choice back and says why, under the field.
   Without JS, the native select stays, styled. Same box as Text Field and Select.
   Everything is tuned with the --kz-combobox-* variables; the words and timings, at the top of
   the script. */
.kodza-combobox {
  --kz-combobox-bg: #ffffff;
  --kz-combobox-fg: #0a0a0a;
  --kz-combobox-muted: rgba(10, 10, 10, 0.6);
  --kz-combobox-line: rgba(10, 10, 10, 0.14);
  --kz-combobox-line-hover: rgba(10, 10, 10, 0.3);
  --kz-combobox-soft: rgba(10, 10, 10, 0.06);
  --kz-combobox-selection: rgba(128, 128, 128, 0.35); /* the selected text: grey, not the browser blue */
  --kz-combobox-error: #0a0a0a; /* a message, never colour alone: set a red here if you want one */
  --kz-combobox-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
  --kz-combobox-height: 56px;
  --kz-combobox-radius: calc(12px * var(--kz-radius-scale, 1));
  --kz-combobox-pad: 16px;
  --kz-combobox-width: var(--kz-field-width, 320px);
  --kz-combobox-list-height: 280px; /* the open list, at most: it scrolls past that */
  --kz-combobox-duration: 0.35s;
  --kz-combobox-ease: cubic-bezier(0.22, 1, 0.36, 1);

  position: relative;
  display: grid;
  gap: 8px;
  width: 100%;
  max-width: var(--kz-combobox-width);
  /* The closed list keeps its size, ready to unroll, but never lengthens the page */
  contain: layout;
  /* A number, not auto, so it can wait for the list to roll up before dropping back */
  z-index: 0;
  transition: z-index 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
  color: var(--kz-combobox-fg);
}

/* Open, the field rises above what follows it (its list included) */
.kodza-combobox.is-open {
  z-index: 20;
  transition-delay: 0s;
}

/* The box: label on top, value below (the native select, or the text field the script adds) */
.kodza-combobox__box {
  position: relative;
  display: block;
  box-sizing: border-box;
  height: var(--kz-combobox-height);
  margin: 0;
  padding: 0;
  border-radius: var(--kz-combobox-radius);
  background: var(--kz-combobox-bg);
  box-shadow: inset 0 0 0 1px var(--kz-combobox-line);
  cursor: text;
  transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__box:hover {
  box-shadow: inset 0 0 0 1px var(--kz-combobox-line-hover);
}

.kodza-combobox__box:focus-within {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-fg), 0 0 0 4px var(--kz-combobox-soft);
}

.kodza-combobox.is-invalid .kodza-combobox__box {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error);
}

.kodza-combobox.is-invalid .kodza-combobox__box:focus-within {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error), 0 0 0 4px var(--kz-combobox-soft);
}

.kodza-combobox.is-disabled .kodza-combobox__box {
  opacity: 0.5;
  cursor: not-allowed;
}

.kodza-combobox__label {
  position: absolute;
  top: 10px;
  left: var(--kz-combobox-pad);
  z-index: 1;
  color: var(--kz-combobox-muted);
  font-size: 12px;
  line-height: 1;
  pointer-events: none;
  transform-origin: left top;
  transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}

/* Nothing chosen yet: the label sits in the field at the size of a value, as in Text Field, and
   rises when the field takes the focus */
.kodza-combobox.is-empty .kodza-combobox__box:not(:focus-within) .kodza-combobox__label {
  transform: translateY(calc(var(--kz-combobox-height) / 2 - 17.5px)) scale(1.25);
}

/* Without the script: the native select, styled like the field */
.kodza-combobox__native {
  display: flex;
  align-items: flex-end;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0 44px 9px var(--kz-combobox-pad);
  border: 0;
  border-radius: inherit;
  outline: none;
  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5 8 10.5l4-4' fill='none' stroke='%23999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 16px center / 16px no-repeat;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  cursor: pointer;
  appearance: none;
}

/* Enhanced: the native select steps back (still in the form), the text field takes its place */
.kodza-combobox.is-ready .kodza-combobox__native {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  clip-path: inset(50%);
}

/* The value line: the text field, and the old value rolling out above it (ghost) */
.kodza-combobox__value {
  position: absolute;
  right: 44px;
  bottom: 9px;
  left: var(--kz-combobox-pad);
  height: 22px;
  overflow: hidden;
}

.kodza-combobox__input,
.kodza-combobox__ghost {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 22px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  letter-spacing: inherit;
  text-overflow: ellipsis;
  white-space: pre;
}

.kodza-combobox__input {
  border: 0;
  border-radius: 0;
  outline: none;
  background: transparent;
  box-shadow: none;
  caret-color: var(--kz-combobox-fg);
  appearance: none;
}

/* Clicking in selects the whole name, ready to be typed over */
.kodza-combobox__input::selection {
  background: var(--kz-combobox-selection);
  color: var(--kz-combobox-fg);
}

.kodza-combobox__input::placeholder {
  color: transparent;
  opacity: 1;
  transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__input:focus::placeholder {
  color: var(--kz-combobox-muted);
}

.kodza-combobox__input:disabled {
  cursor: not-allowed;
}

.kodza-combobox__ghost {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* The chevron: a click opens the list, or closes it */
.kodza-combobox__toggle {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  cursor: pointer;
}

.kodza-combobox.is-disabled .kodza-combobox__toggle {
  cursor: not-allowed;
}

.kodza-combobox__toggle svg {
  display: block;
  width: 16px;
  height: 16px;
  fill: none;
  stroke: var(--kz-combobox-muted);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}

.kodza-combobox.is-open .kodza-combobox__toggle svg {
  transform: rotate(180deg);
}

/* The list unrolls from the field, over the note below it */
.kodza-combobox__list {
  position: absolute;
  top: calc(var(--kz-combobox-height) + 8px);
  right: 0;
  left: 0;
  z-index: 20;
  box-sizing: border-box;
  max-height: var(--kz-combobox-list-height);
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: calc(14px * var(--kz-radius-scale, 1));
  background: var(--kz-combobox-bg);
  box-shadow: 0 0 0 1px var(--kz-combobox-line), var(--kz-combobox-shadow);
  list-style: none;
  visibility: hidden;
  clip-path: inset(0 0 100% 0 round calc(14px * var(--kz-radius-scale, 1)));
  transform: translateY(-4px);
  transition:
    clip-path calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    visibility 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
}

.kodza-combobox.is-open .kodza-combobox__list {
  visibility: visible;
  clip-path: inset(-40px -40px -60px -40px round calc(14px * var(--kz-radius-scale, 1)));
  transform: none;
  transition-delay: 0s;
}

.kodza-combobox.is-above .kodza-combobox__list {
  top: auto;
  bottom: calc(100% + 8px);
  clip-path: inset(100% 0 0 0 round calc(14px * var(--kz-radius-scale, 1)));
  transform: translateY(4px);
}

.kodza-combobox.is-above.is-open .kodza-combobox__list {
  clip-path: inset(-60px -40px -40px -40px round calc(14px * var(--kz-radius-scale, 1)));
  transform: none;
}

.kodza-combobox__highlight {
  position: absolute;
  top: 0;
  right: 6px;
  left: 6px;
  height: var(--kz-hl-h, 36px);
  border-radius: calc(8px * var(--kz-radius-scale, 1));
  background: var(--kz-combobox-soft);
  opacity: var(--kz-hl-on, 0);
  transform: translateY(var(--kz-hl-y, 0));
  pointer-events: none;
}

.kodza-combobox.is-open .kodza-combobox__highlight {
  transition:
    transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__group {
  margin: 0;
  padding: 8px 10px 4px 32px;
  font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 11px);
  font-weight: var(--kz-font-label-weight, inherit);
  line-height: var(--kz-font-label-leading, inherit);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  text-transform: var(--kz-font-label-case, inherit);
  color: var(--kz-combobox-muted);
}

.kodza-combobox__option {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  height: 36px;
  margin: 0;
  padding: 0 10px 0 32px;
  border-radius: calc(8px * var(--kz-radius-scale, 1));
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
}

.kodza-combobox__option[aria-disabled="true"] {
  color: var(--kz-combobox-muted);
  cursor: not-allowed;
}

.kodza-combobox__group[hidden],
.kodza-combobox__option[hidden],
.kodza-combobox__empty[hidden] {
  display: none;
}

.kodza-combobox__text {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The letters that match what was typed */
.kodza-combobox__option mark {
  padding: 0;
  background: none;
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 3px;
}

/* The code on the right (data-meta): ISO code, offset, handle */
.kodza-combobox__meta {
  flex: none;
  margin-left: auto;
  color: var(--kz-combobox-muted);
  font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 12px);
  font-weight: var(--kz-font-label-weight, 400);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  font-variant-numeric: tabular-nums;
}

/* The tick of the chosen option draws itself */
.kodza-combobox__option svg {
  position: absolute;
  left: 10px;
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}

.kodza-combobox__option[aria-selected="true"] svg {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease) calc(0.05s * var(--kz-motion-scale, 1));
}

/* Nothing matches */
.kodza-combobox__empty {
  margin: 0;
  padding: 18px 10px;
  color: var(--kz-combobox-muted);
  font-size: 13px;
  text-align: center;
}

/* Hint and error share one line: the hint steps out upward as the error rises in */
.kodza-combobox__note {
  display: grid;
  margin: 0;
  padding: 0 2px;
  overflow: hidden;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, inherit);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  line-height: var(--kz-font-label-leading, 1.4);
  text-transform: var(--kz-font-label-case, inherit);
}

.kodza-combobox__hint,
.kodza-combobox__error {
  grid-area: 1 / 1;
  transition:
    opacity calc(var(--kz-combobox-duration) * 0.625 * var(--kz-motion-scale, 1)) ease,
    transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}

.kodza-combobox__hint {
  color: var(--kz-combobox-muted);
}

.kodza-combobox__error {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--kz-combobox-error);
  font-weight: var(--kz-font-label-weight, 500);
  opacity: 0;
  transform: translateY(100%);
}

.kodza-combobox__error::before {
  content: "";
  flex: none;
  width: 14px;
  height: 14px;
  background: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}

.kodza-combobox.is-invalid .kodza-combobox__hint {
  opacity: 0;
  transform: translateY(-100%);
}

.kodza-combobox.is-invalid .kodza-combobox__error {
  opacity: 1;
  transform: none;
}

/* What screen readers hear as the list filters ("4 options") */
.kodza-combobox__live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-combobox__box,
  .kodza-combobox__label,
  .kodza-combobox__input::placeholder,
  .kodza-combobox__toggle svg,
  .kodza-combobox__list,
  .kodza-combobox.is-open .kodza-combobox__highlight,
  .kodza-combobox__option svg,
  .kodza-combobox__hint,
  .kodza-combobox__error {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

/* Theming example — on a dark background:
.dark-section .kodza-combobox {
  --kz-combobox-bg: #161616;
  --kz-combobox-fg: #f2f2f0;
  --kz-combobox-muted: rgba(242, 242, 240, 0.62);
  --kz-combobox-line: rgba(255, 255, 255, 0.14);
  --kz-combobox-line-hover: rgba(255, 255, 255, 0.3);
  --kz-combobox-soft: rgba(255, 255, 255, 0.08);
  --kz-combobox-error: #f2f2f0;
  --kz-combobox-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/

/* kodza-guard bbd3901dc7d7 — generated by Kodza, do not edit.
   Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
   component. :not(#_) is always true and weighs as much as an id, so these lines win over
   theme rules made of classes and tags. Customise the component through its --kz-* variables. */

/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */

:where(.kodza-combobox__native):not(#_) {
  position: revert;
}

:where(.kodza-combobox__highlight):not(#_) {
  transition: revert;
}

:where(.kodza-combobox__option):not(#_) {
  color: revert;
}

:where(.kodza-combobox__empty):not(#_):where(:not([hidden])),
:where(.kodza-combobox__group):not(#_):where(:not([hidden])) {
  display: revert;
}

:where(.kodza-combobox__empty):not(#_):where([hidden]),
:where(.kodza-combobox__group):not(#_):where([hidden]) {
  display: none;
}

/* What the browser gives, on everything the component leaves to it */

.kodza-combobox:not(#_),
.kodza-combobox__label:not(#_),
.kodza-combobox__value:not(#_),
.kodza-combobox__toggle:not(#_),
.kodza-combobox__text:not(#_),
.kodza-combobox__meta:not(#_),
.kodza-combobox__hint:not(#_),
.kodza-combobox__error:not(#_),
.kodza-combobox__live:not(#_) {
  box-sizing: revert;
}

.kodza-combobox__box:not(#_) {
  border: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  float: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__input:not(#_) {
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  float: revert;
  position: revert;
  outline-offset: revert;
  max-width: revert;
  max-height: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  min-height: revert;
}

.kodza-combobox__native:not(#_) {
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  float: revert;
  box-shadow: revert;
  outline-offset: revert;
  max-width: revert;
  max-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  white-space: revert;
  min-height: revert;
}

.kodza-combobox__list:not(#_) {
  border: revert;
  outline: revert;
  text-decoration: revert;
  float: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__highlight:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__option:not(#_) {
  border: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__empty:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__note:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__group:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__native option:where(:not([class*="kodza-"])):not(#_),
.kodza-combobox__native optgroup:where(:not([class*="kodza-"])):not(#_) {
  box-sizing: revert;
  color: revert;
}

.kodza-combobox__toggle svg:where(:not([class*="kodza-"])):not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-combobox__list:not(#_):where(:not([hidden])),
.kodza-combobox__highlight:not(#_):where(:not([hidden])),
.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-combobox__list:not(#_):where([hidden]),
.kodza-combobox__highlight:not(#_):where([hidden]),
.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
  display: none;
}

.kodza-combobox__error:not(#_)::before {
  box-sizing: revert;
}

/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */

.kodza-combobox:not(#_) {
  width: min(100%, var(--kz-combobox-width));
}

.kodza-combobox__box:not(#_) {
  position: relative;
  display: block;
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border-radius: var(--kz-combobox-radius);
  background: var(--kz-combobox-bg);
  box-shadow: inset 0 0 0 1px var(--kz-combobox-line);
  transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__box:hover:not(#_) {
  box-shadow: inset 0 0 0 1px var(--kz-combobox-line-hover);
}

.kodza-combobox__box:focus-within:not(#_) {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-fg), 0 0 0 4px var(--kz-combobox-soft);
}

.kodza-combobox.is-invalid .kodza-combobox__box:not(#_) {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error);
}

.kodza-combobox.is-invalid .kodza-combobox__box:focus-within:not(#_) {
  box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error), 0 0 0 4px var(--kz-combobox-soft);
}

.kodza-combobox__native:not(#_) {
  display: flex;
  align-items: flex-end;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0 44px 9px var(--kz-combobox-pad);
  border: 0;
  border-radius: inherit;
  outline: none;
  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5 8 10.5l4-4' fill='none' stroke='%23999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 16px center / 16px no-repeat;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  cursor: pointer;
  appearance: none;
}

.kodza-combobox.is-ready .kodza-combobox__native:not(#_) {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  clip-path: inset(50%);
}

.kodza-combobox__input:not(#_) {
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 22px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  letter-spacing: inherit;
  text-overflow: ellipsis;
  white-space: pre;
}

.kodza-combobox__input:not(#_) {
  border: 0;
  border-radius: 0;
  outline: none;
  background: transparent;
  box-shadow: none;
  caret-color: var(--kz-combobox-fg);
  appearance: none;
}

.kodza-combobox__input:not(#_)::selection {
  background: var(--kz-combobox-selection);
  color: var(--kz-combobox-fg);
}

.kodza-combobox__input:not(#_)::placeholder {
  color: transparent;
  opacity: 1;
  transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__input:focus:not(#_)::placeholder {
  color: var(--kz-combobox-muted);
}

.kodza-combobox__input:disabled:not(#_) {
  cursor: not-allowed;
}

.kodza-combobox__toggle svg:not(#_) {
  display: block;
  transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}

.kodza-combobox__list:not(#_) {
  position: absolute;
  box-sizing: border-box;
  max-height: var(--kz-combobox-list-height);
  margin: 0;
  padding: 6px;
  border-radius: calc(14px * var(--kz-radius-scale, 1));
  background: var(--kz-combobox-bg);
  box-shadow: 0 0 0 1px var(--kz-combobox-line), var(--kz-combobox-shadow);
  list-style: none;
  transition: clip-path calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    visibility 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
}

.kodza-combobox.is-open .kodza-combobox__list:not(#_) {
  transition-delay: 0s;
}

.kodza-combobox__highlight:not(#_) {
  position: absolute;
  border-radius: calc(8px * var(--kz-radius-scale, 1));
  background: var(--kz-combobox-soft);
}

.kodza-combobox.is-open .kodza-combobox__highlight:not(#_) {
  transition: transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
    opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}

.kodza-combobox__group:not(#_) {
  margin: 0;
  padding: 8px 10px 4px 32px;
  font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
  font-size: var(--kz-font-label-size, 11px);
  font-weight: var(--kz-font-label-weight, inherit);
  line-height: var(--kz-font-label-leading, inherit);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  text-transform: var(--kz-font-label-case, inherit);
  color: var(--kz-combobox-muted);
}

.kodza-combobox__option:not(#_) {
  position: relative;
  display: flex;
  margin: 0;
  padding: 0 10px 0 32px;
  border-radius: calc(8px * var(--kz-radius-scale, 1));
  font-size: 14px;
  font-weight: 500;
}

.kodza-combobox__option[aria-disabled="true"]:not(#_) {
  color: var(--kz-combobox-muted);
}

.kodza-combobox__group[hidden]:not(#_),
.kodza-combobox__option[hidden]:not(#_),
.kodza-combobox__empty[hidden]:not(#_) {
  display: none;
}

.kodza-combobox__option svg:not(#_) {
  position: absolute;
  transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}

.kodza-combobox__option[aria-selected="true"] svg:not(#_) {
  transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease) calc(0.05s * var(--kz-motion-scale, 1));
}

.kodza-combobox__empty:not(#_) {
  margin: 0;
  padding: 18px 10px;
  color: var(--kz-combobox-muted);
  font-size: 13px;
  text-align: center;
}

.kodza-combobox__note:not(#_) {
  display: grid;
  margin: 0;
  padding: 0 2px;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, inherit);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  line-height: var(--kz-font-label-leading, 1.4);
  text-transform: var(--kz-font-label-case, inherit);
}

@media (prefers-reduced-motion: reduce) {
  .kodza-combobox__box:not(#_),
  .kodza-combobox__toggle svg:not(#_),
  .kodza-combobox__list:not(#_),
  .kodza-combobox.is-open .kodza-combobox__highlight:not(#_),
  .kodza-combobox__option svg:not(#_) {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kodza-combobox__input:not(#_)::placeholder {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Combobox
// Lays a text field over the real <select> (options, groups, disabled ones) and keeps both in
// step: typing filters the list, accents and case don't matter, the matched letters are
// underlined and the best match is lit, ready for Enter. The code on the right of an option
// (data-meta) is searched too. Leaving the field takes the only match, or an exact one, or puts
// the last choice back and says why under the field; an empty field takes the empty option.
// "change" fires on the real select, so forms and frameworks see an ordinary one. Options
// changed by the page are read again; after setting .value from a script, fire "change" on it.
// The words and timings are below; each word can be set per field with data-text-<name>
// (data-text-nomatch="…"), {query} and {count} are filled in.
(() => {
  const SELECTOR = '.kodza-combobox';
  const WORDS = {
    placeholder: 'Start typing',
    empty: 'No match. Typo?', // the list, when nothing matches
    nomatch: 'No match for “{query}”. Typo?', // under the field, when it is left on no match
    required: 'Pick one from the list.',
    count: '{count} options', // read out as the list filters
    one: '1 option',
  };
  const ROLL = 420; // ms, the value rolling to a new choice
  const RESIZE = 240; // ms, the list changing height as it filters
  const ANNOUNCE = 400; // ms of quiet before the count is read out (a wait, not a motion)
  const PAGE = 5; // rows per PageUp / PageDown
  const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  // The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
  // script times it, and 0 moves nothing, like reduced motion
  const paceOf = (el) => {
    const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
    return pace >= 0 ? pace : 1;
  };
  const tick = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>';
  const chevron = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4"/></svg>';
  let uid = 0;

  // Text as it is searched: lower case, no accents, the typographic minus and apostrophes as
  // typed on a keyboard. `at` gives, for each folded character, its place in the original text
  const fold = (value) => {
    let text = '';
    const at = [];
    let i = 0;
    for (const char of value) {
      const folded = char.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/−/g, '-').replace(/[‘’]/g, "'");
      for (let n = 0; n < folded.length; n += 1) at.push(i);
      text += folded;
      i += char.length;
    }
    at.push(value.length);
    return { text, at };
  };
  // Where the query starts a word ("york" in "new york"), or -1
  const wordAt = (text, query) => {
    for (let s = text.indexOf(query); s >= 0; s = text.indexOf(query, s + 1)) {
      if (!s || !/[\p{L}\p{N}]/u.test(text[s - 1])) return s;
    }
    return -1;
  };
  const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));

  // One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
  function mount(root, signal) {
    const box = root.querySelector('.kodza-combobox__box');
    const native = root.querySelector('.kodza-combobox__native');
    const label = root.querySelector('.kodza-combobox__label');
    const hint = root.querySelector('.kodza-combobox__hint');
    const error = root.querySelector('.kodza-combobox__error');
    if (!box || !native) return;
    const id = `kodza-combobox-${++uid}`;
    const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];

    // The field: a text input on the value line, the old value rolling out above it (ghost)
    const value = document.createElement('span');
    value.className = 'kodza-combobox__value';
    const input = document.createElement('input');
    input.className = 'kodza-combobox__input';
    input.type = 'text';
    input.autocomplete = 'off';
    input.spellcheck = false;
    input.placeholder = word('placeholder');
    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-controls', `${id}-list`);
    if (label) {
      label.id = `${id}-label`;
      input.setAttribute('aria-labelledby', `${id}-label`);
    }
    const described = [];
    if (hint) described.push((hint.id = `${id}-hint`));
    if (error) described.push((error.id = `${id}-error`));
    if (described.length) input.setAttribute('aria-describedby', described.join(' '));
    const ghost = document.createElement('span');
    ghost.className = 'kodza-combobox__ghost';
    ghost.setAttribute('aria-hidden', 'true');
    value.append(input, ghost);
    // Before the real select: the <label> around them now points at the text field
    native.before(value);
    native.tabIndex = -1;
    native.setAttribute('aria-hidden', 'true');
    const toggle = document.createElement('span');
    toggle.className = 'kodza-combobox__toggle';
    toggle.setAttribute('aria-hidden', 'true');
    toggle.innerHTML = chevron;
    box.append(toggle);

    // The list, from the real options (built again whenever the page changes them)
    const list = document.createElement('ul');
    list.className = 'kodza-combobox__list';
    list.id = `${id}-list`;
    list.tabIndex = -1;
    list.setAttribute('role', 'listbox');
    if (label) list.setAttribute('aria-labelledby', `${id}-label`);
    const highlight = document.createElement('li');
    highlight.className = 'kodza-combobox__highlight';
    highlight.setAttribute('role', 'presentation');
    const empty = document.createElement('li');
    empty.className = 'kodza-combobox__empty';
    empty.setAttribute('role', 'presentation');
    empty.hidden = true;
    empty.textContent = word('empty');
    list.append(highlight, empty);
    box.after(list);
    const live = document.createElement('span');
    live.className = 'kodza-combobox__live';
    live.setAttribute('aria-live', 'polite');
    root.append(live);

    // Sections in the order of the select: a group with its heading, or a run of options
    // outside any group. Each entry keeps its folded name and code for the search
    let sections = [];
    let options = [];
    const entries = new Map();
    const build = () => {
      sections = [];
      options = [];
      entries.clear();
      [...list.children].forEach((child) => child !== highlight && child !== empty && child.remove());
      let loose = null;
      [...native.children].forEach((child) => {
        const group = child.tagName === 'OPTGROUP' ? child : null;
        if (!group && child.tagName !== 'OPTION') return;
        let section = loose;
        if (group) {
          const head = document.createElement('li');
          head.className = 'kodza-combobox__group';
          head.setAttribute('role', 'presentation');
          head.textContent = group.label;
          section = { head, entries: [] };
          sections.push(section);
          loose = null;
        } else if (!section) {
          section = loose = { head: null, entries: [] };
          sections.push(section);
        }
        (group ? [...group.children] : [child]).forEach((option) => {
          const text = option.textContent.trim();
          if (option.hidden || !text) return; // the placeholder: "nothing chosen yet" is not a choice
          const item = document.createElement('li');
          item.className = 'kodza-combobox__option';
          item.id = `${id}-option-${options.length}`;
          item.setAttribute('role', 'option');
          item.innerHTML = tick;
          const name = document.createElement('span');
          name.className = 'kodza-combobox__text';
          name.textContent = text;
          item.append(name);
          const meta = option.dataset.meta?.trim() || '';
          let code = null;
          if (meta) {
            code = document.createElement('span');
            code.className = 'kodza-combobox__meta';
            code.textContent = meta;
            item.append(code);
          }
          const disabled = option.disabled || !!group?.disabled;
          if (disabled) item.setAttribute('aria-disabled', 'true');
          const entry = { option, item, text, meta, name, code, disabled, order: options.length, folded: fold(text), metaFolded: fold(meta) };
          section.entries.push(entry);
          options.push(entry);
          entries.set(item, entry);
        });
      });
      sections = sections.filter((section) => section.entries.length);
      sections.forEach(({ head, entries: rows }) => {
        if (head) list.insertBefore(head, empty);
        rows.forEach(({ item }) => list.insertBefore(item, empty));
      });
    };
    build();

    const isOpen = () => root.classList.contains('is-open');
    let active = null;
    let firstShown = null;
    let hits = []; // the enabled rows the query matches, best first
    let shown = []; // the same, in the order of the list (for the arrows)
    let typing = false; // the field holds a query, not the chosen option

    // The matched letters, underlined (a slice of the original text, accents and all)
    const mark = (el, text, folded, start, length) => {
      if (start < 0) return el.replaceChildren(text);
      const from = folded.at[start];
      const to = Math.max(folded.at[start + length], folded.at[start + length - 1] + 1);
      const marked = document.createElement('mark');
      marked.textContent = text.slice(from, to);
      el.replaceChildren(text.slice(0, from), marked, text.slice(to));
    };

    // The list for a query: each section keeps its place, its matches come best first (the name
    // starts with it, then a word of the name or of the code does, then the name or the code
    // holds it), the rest hide. The list changes height smoothly while it is open
    let resize = null;
    let announced = 0;
    const render = (query, speak = true) => {
      const q = fold(query.trim()).text;
      const still = reduceMotion || !paceOf(root);
      const from = isOpen() && !still ? list.offsetHeight : 0;
      resize?.cancel();
      const ranked = [];
      firstShown = null;
      sections.forEach(({ head, entries: rows }) => {
        const found = [];
        const missed = [];
        rows.forEach((entry) => {
          const { text, meta, name, code, folded, metaFolded } = entry;
          let rank = -1;
          let inName = -1;
          let inCode = -1;
          if (!q) rank = 0;
          else if (folded.text.startsWith(q)) [rank, inName] = [0, 0];
          else if ((inName = wordAt(folded.text, q)) >= 0) rank = 1;
          else if ((inCode = wordAt(metaFolded.text, q)) >= 0) rank = 1;
          else if ((inName = folded.text.indexOf(q)) >= 0) rank = 2;
          else if ((inCode = metaFolded.text.indexOf(q)) >= 0) rank = 3;
          mark(name, text, folded, q ? inName : -1, q.length);
          if (code) mark(code, meta, metaFolded, q ? inCode : -1, q.length);
          entry.item.hidden = rank < 0;
          (rank < 0 ? missed : found).push({ entry, rank });
        });
        found.sort((a, b) => a.rank - b.rank || a.entry.order - b.entry.order);
        if (head) {
          head.hidden = !found.length;
          list.insertBefore(head, empty);
          if (found.length && !firstShown) firstShown = head;
        }
        found.forEach(({ entry }) => {
          list.insertBefore(entry.item, empty);
          if (!firstShown) firstShown = entry.item;
        });
        missed.forEach(({ entry }) => list.insertBefore(entry.item, empty));
        ranked.push(...found);
      });
      shown = ranked.filter(({ entry }) => !entry.disabled);
      hits = shown.slice().sort((a, b) => a.rank - b.rank).map(({ entry }) => entry);
      shown = shown.map(({ entry }) => entry);
      empty.hidden = ranked.length > 0;
      if (!ranked.length) firstShown = empty;
      list.scrollTop = 0;
      if (from) {
        const to = list.offsetHeight;
        if (Math.abs(to - from) > 0.5) {
          list.style.overflowY = 'hidden';
          resize = list.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: RESIZE * paceOf(root), easing: ease });
          const done = () => {
            list.style.overflowY = '';
            resize = null;
          };
          resize.onfinish = done;
          resize.oncancel = done;
        }
      }
      // Read out how many are left, once the typing pauses
      clearTimeout(announced);
      const count = ranked.length;
      if (speak) announced = setTimeout(() => {
        live.textContent = !count ? word('empty') : count === 1 ? word('one') : fill(word('count'), { count });
      }, ANNOUNCE);
      return hits[0] || null;
    };

    const light = (entry, scroll = true) => {
      active = entry;
      if (!entry) {
        input.removeAttribute('aria-activedescendant');
        return list.style.setProperty('--kz-hl-on', 0);
      }
      input.setAttribute('aria-activedescendant', entry.item.id);
      list.style.setProperty('--kz-hl-y', `${entry.item.offsetTop}px`);
      list.style.setProperty('--kz-hl-h', `${entry.item.offsetHeight}px`);
      list.style.setProperty('--kz-hl-on', 1);
      // Scroll the list only (scrollIntoView would also nudge the page), with the option's group
      // heading when it opens the group, and the list's own padding above the very first row
      if (scroll) {
        const prev = entry.item.previousElementSibling;
        const first = prev?.classList.contains('kodza-combobox__group') ? prev : entry.item;
        const top = first === firstShown ? 0 : first.offsetTop;
        const bottom = entry.item.offsetTop + entry.item.offsetHeight;
        if (top < list.scrollTop) list.scrollTop = top;
        else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
      }
    };

    const open = () => {
      if (isOpen() || input.disabled) return;
      // Not enough room below and more above: the list opens upwards. A closed list that changes
      // side gets there at once, so it always unrolls from the field. Measuring also settles the
      // highlight where it was put, before the open list starts animating it
      const rect = box.getBoundingClientRect();
      const room = Math.min(parseFloat(getComputedStyle(list).maxHeight) || 280, list.scrollHeight);
      const above = rect.bottom + room + 16 > innerHeight && rect.top > innerHeight / 2;
      if (above !== root.classList.contains('is-above')) {
        list.style.transition = 'none';
        root.classList.toggle('is-above', above);
        void list.offsetWidth;
        list.style.transition = '';
      }
      root.classList.add('is-open');
      input.setAttribute('aria-expanded', 'true');
    };

    // The whole list, lit on the current choice
    const openAll = () => {
      if (isOpen() || input.disabled) return;
      render('');
      const chosen = options.find(({ option }) => option.selected);
      light(chosen && !chosen.disabled ? chosen : hits[0] || null);
      open();
    };

    const close = () => {
      resize?.cancel();
      clearTimeout(announced);
      if (!isOpen()) return;
      root.classList.remove('is-open');
      input.setAttribute('aria-expanded', 'false');
      input.removeAttribute('aria-activedescendant');
    };

    // The value rolls to the new text: the old one goes up, the new one comes from below
    let rolls = [];
    const setText = (text, roll) => {
      const before = input.value;
      if (before === text) return;
      input.value = text;
      rolls.forEach((animation) => animation.cancel());
      rolls = [];
      // Read when the value rolls: read at mount, the style it computes would turn the field's first
      // state (its label folded over a chosen value) into a transition
      const pace = paceOf(root);
      if (!roll || !before || reduceMotion || !pace) return;
      ghost.textContent = before;
      const timing = { duration: ROLL * pace, easing: ease };
      const out = ghost.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' });
      const done = () => {
        if (rolls.includes(out)) ghost.textContent = '';
      };
      out.onfinish = done;
      out.oncancel = () => (ghost.textContent = '');
      rolls = [out, input.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing)];
    };

    // The field follows the real select
    const sync = (roll) => {
      options.forEach(({ option, item }) => item.setAttribute('aria-selected', String(option.selected)));
      input.disabled = native.disabled;
      root.classList.toggle('is-disabled', native.disabled);
      input.setAttribute('aria-required', String(native.required));
      if (typing) return;
      const chosen = native.selectedOptions[0];
      const text = !chosen || chosen.hidden ? '' : chosen.textContent.trim();
      root.classList.toggle('is-empty', !text);
      setText(text, roll);
    };

    // Hint and error share the line under the field
    const say = (text) => {
      if (!error) return;
      error.textContent = text;
      root.classList.add('is-invalid');
      input.setAttribute('aria-invalid', 'true');
    };
    const clearError = () => {
      root.classList.remove('is-invalid');
      input.removeAttribute('aria-invalid');
    };

    const pick = (option) => {
      if (!option || option.disabled || option.parentElement?.disabled) return;
      const changed = !option.selected;
      option.selected = true;
      typing = false;
      clearError();
      sync(true);
      close();
      if (changed) native.dispatchEvent(new Event('change', { bubbles: true }));
    };
    const revert = () => {
      typing = false;
      sync(true);
    };

    // Leaving the field: the only match, or one named exactly, is taken; an empty field takes the
    // empty option; anything else puts the last choice back and says why
    const commit = () => {
      if (!typing) return close();
      const query = input.value.trim();
      const q = fold(query).text;
      if (!q) {
        const blank = [...native.options].find((option) => option.value === '' && !option.disabled);
        if (blank) pick(blank);
        else revert();
        close();
        if (native.required && !native.value) say(word('required'));
        return;
      }
      const exact = hits.find(({ folded, metaFolded }) => folded.text === q || metaFolded.text === q);
      if (exact || hits.length === 1) return pick((exact || hits[0]).option);
      const none = !empty.hidden; // nothing matches at all (a disabled match is not a typo)
      revert();
      close();
      if (none) say(fill(word('nomatch'), { query }));
    };

    // Typing filters, lit on the best match
    input.addEventListener('input', () => {
      typing = true;
      clearError();
      root.classList.remove('is-empty');
      light(render(input.value));
      open();
    }, { signal });

    input.addEventListener('keydown', (event) => {
      if (event.isComposing) return;
      const moves = { ArrowDown: 1, ArrowUp: -1, PageDown: PAGE, PageUp: -PAGE };
      if (event.key in moves) {
        event.preventDefault();
        if (!isOpen()) return openAll();
        if (!shown.length) return;
        const index = shown.indexOf(active);
        const step = moves[event.key];
        const next = index < 0 ? (step > 0 ? 0 : shown.length - 1) : Math.max(0, Math.min(shown.length - 1, index + step));
        light(shown[next]);
      } else if (event.key === 'Enter' && isOpen()) {
        event.preventDefault();
        if (active) pick(active.option);
        else commit();
      } else if (event.key === 'Tab' && isOpen() && active) {
        pick(active.option);
      } else if (event.key === 'Escape' && (isOpen() || typing)) {
        event.preventDefault();
        revert();
        close();
      }
    }, { signal });

    // A click in the field opens the whole list, with the text selected, ready to be typed over
    let pointed = false;
    input.addEventListener('pointerdown', () => (pointed = document.activeElement !== input), { signal });
    input.addEventListener('mouseup', (event) => {
      if (!pointed) return;
      pointed = false;
      event.preventDefault(); // keeps the selection made on focus
    }, { signal });
    input.addEventListener('focus', () => input.select(), { signal });
    input.addEventListener('click', () => !isOpen() && openAll(), { signal });
    input.addEventListener('blur', () => {
      if (document.activeElement === input) return; // the window lost the focus, not the field
      commit();
    }, { signal });

    // The rest of the box (label, padding, chevron) works the field: the focus stays in it and the
    // wrapping <label> never sends the click to the hidden select
    box.addEventListener('mousedown', (event) => event.target !== input && event.preventDefault(), { signal });
    box.addEventListener('click', (event) => {
      if (event.target === input) return;
      event.preventDefault();
      if (input.disabled) return;
      if (toggle.contains(event.target) && isOpen()) {
        revert();
        return close();
      }
      if (document.activeElement !== input) input.focus();
      openAll();
    }, { signal });

    const entryAt = (event) => entries.get(event.target.closest?.('.kodza-combobox__option'));
    list.addEventListener('pointermove', (event) => {
      const entry = entryAt(event);
      if (entry && active !== entry && !entry.disabled) light(entry, false);
    }, { signal });
    list.addEventListener('click', (event) => {
      const entry = entryAt(event);
      if (entry && !entry.disabled) pick(entry.option);
    }, { signal });
    list.addEventListener('mousedown', (event) => event.preventDefault(), { signal });

    // Sent without a choice: the field says it, not the browser's bubble on the hidden select
    native.addEventListener('invalid', (event) => {
      event.preventDefault();
      say(native.validity.customError ? native.validationMessage : word('required'));
      const first = native.form && [...native.form.elements].find((el) => el.willValidate && !el.validity.valid);
      if (!native.form || first === native) input.focus();
    }, { signal });
    // The real select changed from elsewhere (a form reset, a script): follow it
    native.addEventListener('change', () => {
      typing = false;
      sync(true);
    }, { signal });
    native.form?.addEventListener('reset', () => setTimeout(() => {
      typing = false;
      clearError();
      sync(true);
    }), { signal });

    // The page changed the options (counts, disabled ones, a new list): build the list again and
    // filter it as it was, with the value shown without a roll
    let queued = false;
    const observer = new MutationObserver(() => {
      if (queued) return;
      queued = true;
      queueMicrotask(() => {
        queued = false;
        if (signal.aborted) return;
        const was = active?.option;
        build();
        sync(false);
        if (!isOpen()) return;
        const best = render(typing ? input.value : '', false);
        const again = shown.find(({ option }) => option === was);
        light(again || best, false);
      });
    });
    observer.observe(native, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['disabled', 'hidden', 'label', 'data-meta', 'required'] });
    signal.addEventListener('abort', () => {
      observer.disconnect();
      clearTimeout(announced);
      resize?.cancel();
      rolls.forEach((animation) => animation.cancel());
    });

    render('', false);
    sync(false);
  }

  // Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
  // event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
  // markup back. For themes and apps that swap content without a page load. Each running root
  // keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
  // component inside another one is saved as written, not half-started, and stopped with it. Two
  // components on one root (a reveal on a stat card) both start, and stop together.
  const KODZA = Symbol.for('kodza');
  const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
  const instances = new WeakMap();
  const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
  const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
  const put = (el, saved) => {
    [...el.attributes].forEach((a) => el.removeAttribute(a.name));
    saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
    el.innerHTML = saved.html;
  };
  // The markup inside a root as written: Kodza components already running in it give back their own
  const written = (root) => {
    const all = [...root.querySelectorAll('*')];
    if (!all.some((el) => el[KODZA])) return root.innerHTML;
    const copy = root.cloneNode(true);
    const twins = copy.querySelectorAll('*');
    let last = null;
    all.forEach((el, i) => {
      if (!el[KODZA] || last?.contains(el)) return;
      last = el;
      put(twins[i], el[KODZA]);
    });
    return copy.innerHTML;
  };
  const init = (scope) => find(scope).forEach((root) => {
    // Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
    // running on it, a root is a copy of a started one (a cloned slide): it is left as it is
    if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
    // Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
    // it has hydrated it. Any earlier start would change markup React has not taken over yet
    const owner = root.closest('[data-kodza-manual]');
    if (owner && !(scope && owner.contains(scope))) return;
    const controller = new AbortController();
    // Another Kodza component already running on this root: its markup as written is kept, and the
    // two stop together
    const before = root[KODZA];
    const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
    if (before) before.above = saved;
    instances.set(root, { controller, saved, before });
    root[KODZA] = saved;
    root[RUNNING] = true;
    mount(root, controller.signal);
    root.classList.add('is-ready');
  });
  const destroy = (scope) => find(scope).forEach((root) => {
    const instance = instances.get(root);
    if (!instance) return;
    instances.delete(root);
    instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
    // The Kodza components running inside it stop first: once its markup is put back, their own
    // script could not find them any more
    root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
    instance.controller.abort();
    delete root[RUNNING];
    // Started on top of another one: that one stops next, and puts the markup back
    if (instance.before) {
      delete instance.before.above;
      root[KODZA] = instance.before;
      return instance.before.stop();
    }
    delete root[KODZA];
    put(root, instance.saved);
  });
  document.addEventListener('kodza:init', (event) => init(event.detail?.root));
  document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
  init();
  // Loaded in <head>, the script runs before the markup exists: once more when it is parsed
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
```

Free to use, from Kodza (https://kodza.dev).
