# Time Picker

> A time field over the real input: a wheel that snaps and takes the time once it stops, or the free slots of a day.

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

## Files

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

- [`kodza-time-picker.css`](https://kodza.dev/registry/time-picker/kodza-time-picker.css) · 22.1 KB
- [`kodza-time-picker.js`](https://kodza.dev/registry/time-picker/kodza-time-picker.js) · 33.8 KB
- [`kodza-time-picker.html`](https://kodza.dev/registry/time-picker/kodza-time-picker.html) · 0.4 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-time-picker.css https://kodza.dev/registry/time-picker/kodza-time-picker.css
curl -fsSL --create-dirs -o kodza/kodza-time-picker.js https://kodza.dev/registry/time-picker/kodza-time-picker.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-time`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-time-bg: #ffffff`
  - `--kz-time-fg: #0a0a0a`
  - `--kz-time-muted: rgba(10, 10, 10, 0.6)`
  - `--kz-time-line: rgba(10, 10, 10, 0.14)`
  - `--kz-time-line-hover: rgba(10, 10, 10, 0.3)`
  - `--kz-time-soft: rgba(10, 10, 10, 0.06)`
  - `--kz-time-ink: #0a0a0a`
  - `--kz-time-on-ink: #ffffff`
  - `--kz-time-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3)`
  - `--kz-time-height: 56px`
  - `--kz-time-radius: calc(12px * var(--kz-radius-scale, 1))`
  - `--kz-time-pad: 16px`
  - `--kz-time-row: 36px`
  - `--kz-time-col: 64px`
  - `--kz-time-ease: cubic-bezier(0.22, 1, 0.36, 1)`
  - `--kz-time-width: var(--kz-field-width, 320px)`
- Modifiers: `.kodza-time--slots`
- Attributes:
  - `data-clock`
  - `data-index`
  - `data-locale` (e.g. `en-GB`, `en-US`)
  - `data-shape`
  - `data-taken` (e.g. `10:00 11:30 12:30`)
  - `data-time`
- 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.

- On a column of the wheel (focus in `.kodza-time__list`):
  - `ArrowUp` / `ArrowDown`: Turns the column by one line
  - `PageUp` / `PageDown`: Turns it by five lines
  - `Home` / `End`: Goes to the first or the last line allowed
  - `ArrowLeft` / `ArrowRight`: Moves to the next column
  - `Enter` / `Space`: Takes the time and closes the panel
- On a slot (focus in `.kodza-time__slot`):
  - `ArrowLeft` / `ArrowRight`: Moves to the previous or next free slot
  - `ArrowUp` / `ArrowDown`: Moves to the free slot above or below
  - `Home` / `End`: Goes to the first or the last free slot
  - `Enter` / `Space`: Takes the slot and closes the panel
- On the field (focus in `.kodza-time__trigger`):
  - `Enter` / `Space`: Opens the panel on the chosen time
- Anywhere, while it's open (while `.kodza-time.is-open` is on the page):
  - `Escape`: Closes it, focus back on the field

## 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-time {
    --kz-time-bg: #161616;
    --kz-time-fg: #f2f2f0;
    --kz-time-muted: rgba(242, 242, 240, 0.62);
    --kz-time-line: rgba(255, 255, 255, 0.14);
    --kz-time-line-hover: rgba(255, 255, 255, 0.3);
    --kz-time-soft: rgba(255, 255, 255, 0.08);
    --kz-time-ink: #f2f2f0;
    --kz-time-on-ink: #0a0a0a;
    --kz-time-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-time { … }`); a single dark section, under that section's class.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-time-picker.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-time-picker.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-time-picker.js`, loaded with `<script src="kodza/kodza-time-picker.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); `--kz-radius-pill` sets the pills (`0` squares them).
- 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-time-picker.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-time-picker.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); `--kz-radius-pill` sets the pills (`0` squares them).
- 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-time-picker', $uri . 'kodza-time-picker.css', [], filemtime($dir . 'kodza-time-picker.css'));
    wp_enqueue_script('kodza-time-picker', $uri . 'kodza-time-picker.js', [], filemtime($dir . 'kodza-time-picker.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-time { --kz-time-fg: var(TEXT, #0a0a0a); --kz-time-bg: var(BACKGROUND, #ffffff); --kz-time-ink: var(ACCENT, #0a0a0a); --kz-time-on-ink: 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-time-picker.css` and `assets/kodza-time-picker.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-time-picker.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-time-picker.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/`): `TimePicker.tsx` (below), the CSS block under Code saved as `time-picker.css` and the JS block as `time-picker.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { TimePicker } from '@/components/kodza/TimePicker';` then `<TimePicker>Deploy at</TimePicker>`. Props: `variant` ("slots", or several in an array), `locale` (`data-locale`: "en-GB", "en-US"), `taken` (`data-taken`: "10:00 11:30 12:30"), `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-time-bg': … }}`), `children` (the text, plain), `className`, `style` and the other props of its `<label>` (`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 './time-picker.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 — Time Picker, for React. From https://kodza.dev/components/time-picker/
// 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: time-picker.css and time-picker.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 './time-picker.css';

type Variant = "slots";

export type TimePickerProps = Omit<ComponentPropsWithoutRef<'label'>, "children" | "variant" | "vars" | "locale" | "taken"> & {
  /** Modifier class(es): .kodza-time--slots */
  variant?: Variant | Variant[];
  /** data-locale on the root (default: en-GB) */
  locale?: "en-GB" | "en-US";
  /** data-taken on the root */
  taken?: "10:00 11:30 12:30";
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #ffffff */
    "--kz-time-bg"?: string | number;
    /** default: #0a0a0a */
    "--kz-time-fg"?: string | number;
    /** default: rgba(10, 10, 10, 0.6) */
    "--kz-time-muted"?: string | number;
    /** default: rgba(10, 10, 10, 0.14) */
    "--kz-time-line"?: string | number;
    /** default: rgba(10, 10, 10, 0.3) */
    "--kz-time-line-hover"?: string | number;
    /** default: rgba(10, 10, 10, 0.06) */
    "--kz-time-soft"?: string | number;
    /** default: #0a0a0a */
    "--kz-time-ink"?: string | number;
    /** default: #ffffff */
    "--kz-time-on-ink"?: string | number;
    /** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
    "--kz-time-shadow"?: string | number;
    /** default: 56px */
    "--kz-time-height"?: string | number;
    /** default: calc(12px * var(--kz-radius-scale, 1)) */
    "--kz-time-radius"?: string | number;
    /** default: 16px */
    "--kz-time-pad"?: string | number;
    /** default: 36px */
    "--kz-time-row"?: string | number;
    /** default: 64px */
    "--kz-time-col"?: string | number;
    /** default: cubic-bezier(0.22, 1, 0.36, 1) */
    "--kz-time-ease"?: string | number;
    /** default: var(--kz-field-width, 320px) */
    "--kz-time-width"?: string | number;
  };
  /** The text, plain: the script reads it and rebuilds the markup around it */
  children?: string;
  className?: string;
};

export function TimePicker({ variant, locale = "en-GB", taken, vars, children = "Deploy at", className, style, ...rest }: TimePickerProps) {
  const ref = useRef<ComponentRef<'label'>>(null);
  const classes = ["kodza-time", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-time--${v}`), 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, locale, taken, vars, children, 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('./time-picker.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 (
    <label key={key} {...rest} className={classes} data-kodza-manual data-locale={locale} data-taken={taken} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <span className="kodza-time__label">{children}</span>
      {' '}
      <input className="kodza-time__native" type="time" name="deploy" defaultValue="16:55" step="300" />
    </label>
  );
}
```

## Code

```html
<label class="kodza-time" data-locale="en-GB">
  <span class="kodza-time__label">Deploy at</span>
  <input class="kodza-time__native" type="time" name="deploy" value="16:55" step="300">
</label>

<label class="kodza-time kodza-time--slots" data-locale="en-US" data-taken="10:00 11:30 12:30">
  <span class="kodza-time__label">Review call</span>
  <input class="kodza-time__native" type="time" name="call" min="09:00" max="13:00" step="1800">
</label>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Time Picker
   A real time input, dressed up: the field shows the time the way the locale says it (12 or
   24 hours), and a wheel unrolls under it. Hour and minute columns (and AM / PM) scroll and
   snap on the value, read through an ink band where the digits turn white exactly at its
   edges, and the time rolls into the field part by part. With .kodza-time--slots, the panel
   shows the free slots of a day instead, taken ones struck out, and the ink mark travels to
   the one you pick. Same box as Select and Date Picker. Everything is tuned with the
   --kz-time-* variables. */
.kodza-time {
  --kz-time-bg: #ffffff;
  --kz-time-fg: #0a0a0a;
  --kz-time-muted: rgba(10, 10, 10, 0.6);
  --kz-time-line: rgba(10, 10, 10, 0.14);
  --kz-time-line-hover: rgba(10, 10, 10, 0.3);
  --kz-time-soft: rgba(10, 10, 10, 0.06);
  --kz-time-ink: #0a0a0a;
  --kz-time-on-ink: #ffffff;
  --kz-time-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
  --kz-time-height: 56px;
  --kz-time-radius: calc(12px * var(--kz-radius-scale, 1));
  --kz-time-pad: 16px;
  /* One line of the wheel, and the width of one of its columns */
  --kz-time-row: 36px;
  --kz-time-col: 64px;
  --kz-time-ease: cubic-bezier(0.22, 1, 0.36, 1);
  --kz-time-width: var(--kz-field-width, 320px);

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

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

.kodza-time__label {
  position: absolute;
  top: 10px;
  left: var(--kz-time-pad);
  z-index: 1;
  color: var(--kz-time-muted);
  font-size: 12px;
  line-height: 1;
  pointer-events: none;
}

.kodza-time__native,
.kodza-time__trigger {
  display: flex;
  align-items: flex-end;
  width: 100%;
  height: var(--kz-time-height);
  margin: 0;
  padding: 0 44px 9px var(--kz-time-pad);
  border: 0;
  border-radius: var(--kz-time-radius);
  background: var(--kz-time-bg);
  box-shadow: inset 0 0 0 1px var(--kz-time-line);
  box-sizing: border-box;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  text-align: left;
  cursor: pointer;
  transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__native:hover,
.kodza-time__trigger:hover {
  box-shadow: inset 0 0 0 1px var(--kz-time-line-hover);
}

.kodza-time__native:focus-visible,
.kodza-time__trigger:focus-visible,
.kodza-time.is-open .kodza-time__trigger {
  outline: none;
  box-shadow: inset 0 0 0 1.5px var(--kz-time-fg), 0 0 0 4px var(--kz-time-soft);
}

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

/* The time in the field: each part (hours, minutes, AM / PM) rolls on its own, and its width
   follows when the new face is wider or narrower */
.kodza-time__value,
.kodza-time__part {
  display: inline-grid;
  overflow: hidden;
}

.kodza-time__value > span,
.kodza-time__part > span {
  grid-area: 1 / 1;
  justify-self: start;
  white-space: pre;
}

.kodza-time__value .is-empty {
  color: var(--kz-time-muted);
}

.kodza-time__line {
  display: inline-flex;
  font-variant-numeric: tabular-nums;
}

.kodza-time__icon {
  position: absolute;
  top: calc(var(--kz-time-height) / 2 - 9px);
  right: 16px;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: var(--kz-time-muted);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  pointer-events: none;
}

/* The panel unrolls under the field */
.kodza-time__pop {
  position: absolute;
  top: calc(var(--kz-time-height) + 8px);
  left: 0;
  z-index: 20;
  width: 100%;
  padding: 12px;
  border-radius: calc(16px * var(--kz-radius-scale, 1));
  background: var(--kz-time-bg);
  box-shadow: 0 0 0 1px var(--kz-time-line), var(--kz-time-shadow);
  box-sizing: border-box;
  visibility: hidden;
  opacity: 0;
  clip-path: inset(0 0 100% 0 round calc(16px * var(--kz-radius-scale, 1)));
  transform: translateY(-4px);
  transition:
    clip-path calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
    opacity calc(0.2s * var(--kz-motion-scale, 1)) ease,
    transform calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
    visibility 0s linear calc(0.4s * var(--kz-motion-scale, 1));
}

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

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

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

/* The wheel: five lines a column, the value on the middle one, under the ink band */
.kodza-time__wheel {
  position: relative;
  display: flex;
  justify-content: center;
  gap: 4px;
  width: max-content;
  margin: 0 auto;
  padding: 0 6px;
}

.kodza-time__band {
  position: absolute;
  top: calc(var(--kz-time-row) * 2);
  right: 0;
  left: 0;
  height: var(--kz-time-row);
  border-radius: var(--kz-radius-pill, 999px);
  background: var(--kz-time-ink);
  pointer-events: none;
}

.kodza-time__col {
  position: relative;
  border-radius: var(--kz-time-radius);
}


.kodza-time__list {
  position: relative;
  width: var(--kz-time-col);
  height: calc(var(--kz-time-row) * 5);
  overflow-y: auto;
  overscroll-behavior: contain;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
  touch-action: pan-y;
  outline: none;
  color: var(--kz-time-fg);
  /* The far lines fade out, like the edge of a drum */
  -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), #000 calc(var(--kz-time-row) * 1.75), #000 calc(var(--kz-time-row) * 3.25), rgba(0, 0, 0, 0.12));
  mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.12), #000 calc(var(--kz-time-row) * 1.75), #000 calc(var(--kz-time-row) * 3.25), rgba(0, 0, 0, 0.12));
}

.kodza-time__list::-webkit-scrollbar {
  display: none;
}

/* Room for the first and the last value to reach the band */
.kodza-time__list::before,
.kodza-time__list::after {
  content: "";
  display: block;
  height: calc(var(--kz-time-row) * 2);
}

/* Sent to a value (click, keys), a column glides there by script, snapping off on the way */
.kodza-time__list.is-gliding {
  scroll-snap-type: none;
}

.kodza-time__item,
.kodza-time__ghost {
  display: grid;
  place-items: center;
  height: var(--kz-time-row);
  font-size: 16px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

.kodza-time__item {
  scroll-snap-align: center;
  cursor: pointer;
  user-select: none;
}

/* The lens: a white copy of the column, cut to the band, so the digits turn white exactly at
   its edges whatever the scroll */
.kodza-time__lens {
  position: absolute;
  top: calc(var(--kz-time-row) * 2);
  right: 0;
  left: 0;
  height: var(--kz-time-row);
  overflow: hidden;
  border-radius: var(--kz-radius-pill, 999px);
  color: var(--kz-time-on-ink);
  pointer-events: none;
}

/* Focus from the keyboard: a ring round the value of the column, inside the band */
.kodza-time__col:has(> .kodza-time__list:focus-visible) .kodza-time__lens {
  box-shadow: inset 0 0 0 3px var(--kz-time-ink), inset 0 0 0 4.5px var(--kz-time-on-ink);
}

.kodza-time__reel {
  will-change: transform;
}

/* Out of min / max: still on the wheel, but faded, and the wheel slides back off it */
.kodza-time__item.is-off,
.kodza-time__ghost.is-off {
  opacity: 0.3;
  cursor: default;
}

.kodza-time__sep {
  position: relative;
  align-self: center;
  width: 8px;
  color: var(--kz-time-on-ink);
  font-size: 16px;
  font-weight: 500;
  line-height: var(--kz-time-row);
  text-align: center;
  pointer-events: none;
}

.kodza-time__foot {
  display: flex;
  justify-content: flex-end;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--kz-time-soft);
}

.kodza-time__done {
  display: grid;
  place-items: center;
  height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  color: inherit;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, 500);
  letter-spacing: var(--kz-font-label-tracking, revert);
  text-transform: var(--kz-font-label-case, revert);
  cursor: pointer;
  transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__done:hover {
  background: var(--kz-time-soft);
}

/* The slots of a day */
.kodza-time__count {
  margin: 2px 4px 10px;
  color: var(--kz-time-muted);
  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, 1.4);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  text-transform: var(--kz-font-label-case, inherit);
}

.kodza-time__slots {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 6px;
  /* Room for the focus ring, which the scroll box would clip */
  margin: -4px;
  padding: 4px;
  max-height: 272px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
}

.kodza-time__slot {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  height: 36px;
  margin: 0;
  padding: 0 6px;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  box-shadow: inset 0 0 0 1px var(--kz-time-line);
  color: inherit;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, 500);
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: var(--kz-font-label-tracking, revert);
  text-transform: var(--kz-font-label-case, revert);
  white-space: nowrap;
  cursor: pointer;
  transition:
    color calc(0.2s * var(--kz-motion-scale, 1)) ease calc(0.1s * var(--kz-motion-scale, 1)),
    background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
    box-shadow calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__slot:hover {
  background: var(--kz-time-soft);
}

.kodza-time__slot[aria-pressed="true"] {
  color: var(--kz-time-on-ink);
  box-shadow: inset 0 0 0 1px transparent;
}

.kodza-time__slot[aria-pressed="true"]:hover {
  background: none;
}

.kodza-time__slot:disabled {
  color: var(--kz-time-muted);
  box-shadow: inset 0 0 0 1px var(--kz-time-soft);
  text-decoration: line-through;
  cursor: not-allowed;
}

.kodza-time__slot:disabled:hover {
  background: none;
}

.kodza-time__slot:focus-visible,
.kodza-time__done:focus-visible {
  outline: 2px solid var(--kz-time-fg);
  outline-offset: 2px;
}

/* The ink mark: it travels to the slot you pick */
.kodza-time__mark {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--kz-mark-w, 0);
  height: var(--kz-mark-h, 0);
  border-radius: var(--kz-radius-pill, 999px);
  background: var(--kz-time-ink);
  transform: translate(var(--kz-mark-x, 0), var(--kz-mark-y, 0));
  transition:
    transform calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-time-ease),
    opacity calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__mark.is-hidden {
  opacity: 0;
  transform: translate(var(--kz-mark-x, 0), var(--kz-mark-y, 0)) scale(0.4);
}

@media (prefers-reduced-motion: reduce) {
  .kodza-time__native,
  .kodza-time__trigger,
  .kodza-time__pop,
  .kodza-time__slot,
  .kodza-time__mark {
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}

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

/* kodza-guard 06594cb6bd2f — 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-time__native):not(#_) {
  outline: revert;
  position: revert;
}

:where(.kodza-time__trigger):not(#_) {
  outline: revert;
}

:where(.kodza-time__done):not(#_) {
  outline: revert;
  outline-offset: revert;
}

:where(.kodza-time__slot):not(#_) {
  outline: revert;
  text-decoration: revert;
  outline-offset: revert;
}

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

.kodza-time:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: 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-time__label:not(#_),
.kodza-time__value:not(#_),
.kodza-time__line:not(#_),
.kodza-time__part:not(#_),
.kodza-time__wheel:not(#_),
.kodza-time__band:not(#_),
.kodza-time__col:not(#_),
.kodza-time__item:not(#_),
.kodza-time__lens:not(#_),
.kodza-time__reel:not(#_),
.kodza-time__ghost:not(#_),
.kodza-time__sep:not(#_),
.kodza-time__foot:not(#_),
.kodza-time__slots:not(#_),
.kodza-time__mark:not(#_),
.kodza-time__part span:where(:not([class*="kodza-"])):not(#_),
.kodza-time__value span:where(:not([class*="kodza-"])):not(#_) {
  box-sizing: revert;
}

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

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

.kodza-time__icon:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: 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;
  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-time__list:not(#_) {
  box-sizing: revert;
  box-shadow: revert;
  outline-offset: revert;
}

.kodza-time__done:not(#_) {
  margin: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  box-shadow: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  line-height: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  white-space: revert;
  min-height: revert;
}

.kodza-time__count:not(#_) {
  border: revert;
  padding: 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-time__slot:not(#_) {
  list-style: revert;
  box-sizing: revert;
  float: 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;
  min-height: revert;
}

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

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

.kodza-time__list:not(#_)::before,
.kodza-time__list:not(#_)::after {
  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-time:not(#_) {
  position: relative;
  display: block;
  transition: z-index 0s linear calc(0.4s * var(--kz-motion-scale, 1));
  color: var(--kz-time-fg);
}

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

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

.kodza-time__native:not(#_),
.kodza-time__trigger:not(#_) {
  display: flex;
  align-items: flex-end;
  width: 100%;
  height: var(--kz-time-height);
  margin: 0;
  padding: 0 44px 9px var(--kz-time-pad);
  border: 0;
  border-radius: var(--kz-time-radius);
  background: var(--kz-time-bg);
  box-shadow: inset 0 0 0 1px var(--kz-time-line);
  box-sizing: border-box;
  color: inherit;
  font: inherit;
  font-size: 15px;
  line-height: 22px;
  text-align: left;
  cursor: pointer;
  transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__native:hover:not(#_),
.kodza-time__trigger:hover:not(#_) {
  box-shadow: inset 0 0 0 1px var(--kz-time-line-hover);
}

.kodza-time__native:focus-visible:not(#_),
.kodza-time__trigger:focus-visible:not(#_),
.kodza-time.is-open .kodza-time__trigger:not(#_) {
  outline: none;
  box-shadow: inset 0 0 0 1.5px var(--kz-time-fg), 0 0 0 4px var(--kz-time-soft);
}

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

.kodza-time__icon:not(#_) {
  position: absolute;
}

.kodza-time__list:not(#_) {
  outline: none;
}

.kodza-time__done:not(#_) {
  display: grid;
  place-items: center;
  height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  color: inherit;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, 500);
  letter-spacing: var(--kz-font-label-tracking, revert);
  text-transform: var(--kz-font-label-case, revert);
  cursor: pointer;
  transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__done:hover:not(#_) {
  background: var(--kz-time-soft);
}

.kodza-time__count:not(#_) {
  margin: 2px 4px 10px;
  color: var(--kz-time-muted);
  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, 1.4);
  letter-spacing: var(--kz-font-label-tracking, inherit);
  text-transform: var(--kz-font-label-case, inherit);
}

.kodza-time__slot:not(#_) {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  height: 36px;
  margin: 0;
  padding: 0 6px;
  border: 0;
  border-radius: var(--kz-radius-pill, 999px);
  background: none;
  box-shadow: inset 0 0 0 1px var(--kz-time-line);
  color: inherit;
  font-family: var(--kz-font-label, inherit);
  font-size: var(--kz-font-label-size, 13px);
  font-weight: var(--kz-font-label-weight, 500);
  font-variant-numeric: tabular-nums;
  line-height: 1;
  letter-spacing: var(--kz-font-label-tracking, revert);
  text-transform: var(--kz-font-label-case, revert);
  white-space: nowrap;
  cursor: pointer;
  transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease calc(0.1s * var(--kz-motion-scale, 1)),
    background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
    box-shadow calc(0.2s * var(--kz-motion-scale, 1)) ease;
}

.kodza-time__slot:hover:not(#_) {
  background: var(--kz-time-soft);
}

.kodza-time__slot[aria-pressed="true"]:not(#_) {
  color: var(--kz-time-on-ink);
  box-shadow: inset 0 0 0 1px transparent;
}

.kodza-time__slot[aria-pressed="true"]:hover:not(#_) {
  background: none;
}

.kodza-time__slot:disabled:not(#_) {
  color: var(--kz-time-muted);
  box-shadow: inset 0 0 0 1px var(--kz-time-soft);
  text-decoration: line-through;
  cursor: not-allowed;
}

.kodza-time__slot:disabled:hover:not(#_) {
  background: none;
}

.kodza-time__slot:focus-visible:not(#_),
.kodza-time__done:focus-visible:not(#_) {
  outline: 2px solid var(--kz-time-fg);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-time__native:not(#_),
  .kodza-time__trigger:not(#_),
  .kodza-time__slot:not(#_) {
    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 — Time Picker
// Builds a field and a panel over the real <input type="time"> and keeps it in step (firing
// "input" and "change" on it). 12 or 24 hours as data-locale (or the page language) says,
// data-clock="12" or "24" to force it; min, max and step are read on the input. The panel is a
// wheel: hours, minutes (and AM / PM), each column a listbox that scrolls and snaps. Arrows turn
// a column by one, Page Up / Down by five, Home / End to the ends, Left / Right to the next
// column, Enter takes it. Once the wheel stops, the time is taken on its own, and a time out of
// min / max glides back to the nearest one allowed. With .kodza-time--slots, the panel lists the
// slots of a day instead (min to max, or DAY_START to DAY_END, every step, or SLOT_STEP), and the
// times in data-taken="10:00 11:30" are struck out. Escape closes and gives the focus back.
// The words and timings are below; each word can be set per field with data-text-<name>
// (data-text-empty="…"), {free}, {total} and {time} are filled in.
(() => {
  const SELECTOR = '.kodza-time';
  const WORDS = {
    empty: 'Pick a time', // the field, with no time yet
    choose: 'choose a time', // the panel, read after the label
    hours: 'Hours',
    minutes: 'Minutes',
    period: 'AM or PM',
    done: 'Done',
    free: '{free} of {total} free', // above the slots
    taken: '{time}, taken', // a taken slot, read out
  };
  const ROLL = 420; // ms, a part of the field rolling to its new value
  const GLIDE = 360; // ms, the wheel gliding to a time (a click, the keys, back into min / max)
  const SETTLE = 160; // ms of stillness before the wheel takes its time (a wait, not a motion)
  const CLOSE_AFTER = 320; // ms, the mark landing on a slot before the panel closes
  const PAGE = 5; // lines per Page Up / Page Down
  const WHEEL_STEP = 300; // s, the wheel's minutes when the input has no step (every 5 minutes)
  const SLOT_STEP = 1800; // s, the slots when the input has no step (every 30 minutes)
  const DAY_START = '09:00'; // the first slot of a day, when the input has no min
  const DAY_END = '17:00'; // and the last one, when it has no max
  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 easeOut = (x) => 1 - (1 - x) ** 4;
  const DAY = 1440; // minutes
  // A time is counted in minutes from midnight
  const toMinutes = (value) => {
    const m = /^(\d{1,2}):(\d{2})/.exec(value || '');
    if (!m) return null;
    const h = Number(m[1]);
    const min = Number(m[2]);
    return h < 24 && min < 60 ? h * 60 + min : null;
  };
  const toText = (t) => `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
  const at = (t) => new Date(2000, 0, 1, Math.floor(t / 60), t % 60);
  const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
  const icon = '<svg class="kodza-time__icon" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><path d="M9 5.5V9l2.5 1.5"/></svg>';
  let uid = 0;

  // A part of the time rolls to its new face (up when the time goes forward, down when it goes
  // back), and its box follows the new width
  const swap = (box, next, dir, pace) => {
    const from = box.offsetWidth;
    const prev = box.lastElementChild;
    box.append(next);
    if (!prev) return;
    if (reduceMotion || !pace) return prev.remove();
    const to = next.offsetWidth;
    const timing = { duration: ROLL * pace, easing: ease };
    const fit = from !== to ? box.animate([{ width: `${from}px` }, { width: `${to}px` }], { ...timing, fill: 'forwards' }) : null;
    // One keyframe: a face sent off while it was still arriving leaves from where it is
    prev.animate({ transform: `translateY(${dir * -110}%)` }, { ...timing, fill: 'forwards' }).finished.then(() => {
      prev.remove();
      fit?.cancel();
    }, () => {});
    next.animate([{ transform: `translateY(${dir * 110}%)` }, { transform: 'none' }], timing);
  };

  // One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
  function mount(root, signal) {
    // The pace is read once the field is built (end of mount): read before, the style it computes
    // would turn the field's first state into a transition
    let pace = 1;
    let still = reduceMotion; // the still path: no motion asked, by the visitor or the site
    const native = root.querySelector('.kodza-time__native');
    const label = root.querySelector('.kodza-time__label');
    if (!native) return;
    const id = `kodza-time-${++uid}`;
    const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];
    const locale = root.dataset.locale || document.documentElement.lang || undefined;
    const slots = root.classList.contains('kodza-time--slots');
    // 12 or 24 hours: data-clock, or what the locale uses
    const h12 = root.dataset.clock ? root.dataset.clock === '12' : (() => {
      const options = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions();
      return options.hourCycle ? options.hourCycle === 'h11' || options.hourCycle === 'h12' : !!options.hour12;
    })();
    const shown = new Intl.DateTimeFormat(locale, h12 ? { hour: 'numeric', minute: '2-digit', hourCycle: 'h12' } : { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
    const min = toMinutes(native.min);
    const max = toMinutes(native.max);
    const wraps = min !== null && max !== null && min > max; // a range past midnight (22:00 to 02:00)
    const allowed = (t) => (wraps ? t >= min || t <= max : (min === null || t >= min) && (max === null || t <= max));
    const step = Math.max(1, Math.round((parseFloat(native.step) || (slots ? SLOT_STEP : WHEEL_STEP)) / 60));
    let current = toMinutes(native.value);

    // The field
    const trigger = document.createElement('button');
    trigger.type = 'button';
    trigger.className = 'kodza-time__trigger';
    trigger.setAttribute('aria-haspopup', 'dialog');
    trigger.setAttribute('aria-expanded', 'false');
    if (label) {
      label.id = `${id}-label`;
      trigger.setAttribute('aria-labelledby', `${id}-label ${id}-value`);
    }
    const value = Object.assign(document.createElement('span'), { className: 'kodza-time__value', id: `${id}-value` });
    trigger.append(value);
    trigger.insertAdjacentHTML('beforeend', icon);

    // The panel
    const pop = document.createElement('div');
    pop.className = 'kodza-time__pop';
    pop.setAttribute('role', 'dialog');
    pop.setAttribute('aria-label', label ? `${label.textContent}: ${word('choose')}` : word('choose').replace(/^./, (c) => c.toUpperCase()));
    native.after(trigger, pop);
    native.tabIndex = -1;
    native.setAttribute('aria-hidden', 'true');

    // The time in the field, part by part (hours, the colon, minutes, AM / PM), so each part can
    // roll on its own
    let shownTime;
    const line = (t) => {
      if (t === null) {
        const empty = Object.assign(document.createElement('span'), { className: 'is-empty', textContent: word('empty') });
        empty.dataset.shape = 'empty';
        return empty;
      }
      const parts = shown.formatToParts(at(t));
      const box = Object.assign(document.createElement('span'), { className: 'kodza-time__line' });
      box.dataset.shape = parts.map((p) => p.type).join(' ');
      parts.forEach((p) => {
        const part = Object.assign(document.createElement('span'), { className: 'kodza-time__part' });
        part.append(Object.assign(document.createElement('span'), { textContent: p.value }));
        box.append(part);
      });
      return box;
    };
    const show = (t, animate) => {
      if (t === shownTime) return;
      const dir = t !== null && shownTime != null && t < shownTime ? -1 : 1;
      shownTime = t;
      const next = line(t);
      const old = value.lastElementChild;
      if (!animate || !old) return value.replaceChildren(next);
      // A time after a time: only the parts that change roll
      if (old.classList.contains('kodza-time__line') && old.dataset.shape === next.dataset.shape) {
        [...next.children].forEach((part, i) => {
          const face = part.firstElementChild;
          if (old.children[i].lastElementChild.textContent !== face.textContent) swap(old.children[i], face, dir, pace);
        });
        return;
      }
      swap(value, next, dir, pace);
    };

    // Take a time: the real input and its events, then the field
    let busy = false;
    const commit = (t) => {
      if (t !== current) {
        current = t;
        native.value = t === null ? '' : toText(t);
        busy = true;
        try {
          native.dispatchEvent(new Event('input', { bubbles: true }));
          native.dispatchEvent(new Event('change', { bubbles: true }));
        } finally {
          busy = false;
        }
      }
      show(t, true);
    };

    const isOpen = () => root.classList.contains('is-open');
    let timer = 0;
    let frame = 0;
    let prepare; // sets the panel for the time, gives what takes the focus
    let flush = () => {}; // takes what the panel holds, as it closes
    let follow = () => {}; // the time was changed from outside

    if (!slots) {
      // The wheel. Its times: every step from min (or midnight), within min / max
      const base = min ?? 0;
      const grid = [];
      const faces = new Set();
      for (let t = base % step; t < DAY; t += step) {
        faces.add(t % 60);
        if (allowed(t)) grid.push(t);
      }
      const valid = new Set(grid);
      const minutes = [...faces].sort((a, b) => a - b);
      const gap = (a, b) => {
        const d = Math.abs(a - b);
        return wraps ? Math.min(d, DAY - d) : d;
      };
      const nearest = (t) => (valid.has(t) || !grid.length ? t : grid.reduce((best, g) => (gap(g, t) < gap(best, t) ? g : best)));
      const now = () => {
        const d = new Date();
        return d.getHours() * 60 + d.getMinutes();
      };

      // The columns
      const two = new Intl.NumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false });
      const one = new Intl.NumberFormat(locale, { useGrouping: false });
      const sample = shown.formatToParts(at(0));
      const hourAt = sample.findIndex((p) => p.type === 'hour');
      const sep = (sample[hourAt + 1]?.type === 'literal' && sample[hourAt + 1].value.trim()) || ':';
      const column = (name, labels) => {
        const col = Object.assign(document.createElement('div'), { className: 'kodza-time__col' });
        const list = Object.assign(document.createElement('div'), { className: 'kodza-time__list', tabIndex: 0 });
        list.setAttribute('role', 'listbox');
        list.setAttribute('aria-label', word(name));
        const items = labels.map((text, i) => {
          const item = Object.assign(document.createElement('div'), { className: 'kodza-time__item', textContent: text, id: `${id}-${name}-${i}` });
          item.setAttribute('role', 'option');
          item.setAttribute('aria-selected', 'false');
          item.dataset.index = i;
          list.append(item);
          return item;
        });
        // The lens: a white copy of the column, cut to the band, moved with the scroll
        const lens = Object.assign(document.createElement('div'), { className: 'kodza-time__lens' });
        const reel = Object.assign(document.createElement('div'), { className: 'kodza-time__reel' });
        lens.setAttribute('aria-hidden', 'true');
        const ghosts = labels.map((text) => reel.appendChild(Object.assign(document.createElement('span'), { className: 'kodza-time__ghost', textContent: text })));
        lens.append(reel);
        col.append(list, lens);
        return { name, col, list, items, reel, ghosts, index: -1, to: 0 };
      };
      const hours = column('hours', Array.from({ length: h12 ? 12 : 24 }, (_, i) => (h12 ? one.format(i || 12) : two.format(i))));
      const mins = column('minutes', minutes.map((m) => two.format(m)));
      const period = h12 ? column('period', [0, 720].map((t) => shown.formatToParts(at(t)).find((p) => p.type === 'dayPeriod')?.value || (t ? 'PM' : 'AM'))) : null;
      // AM / PM goes first where the locale writes it first
      const periodFirst = period && sample.findIndex((p) => p.type === 'dayPeriod') < hourAt;
      const cols = (periodFirst ? [period, hours, mins] : [hours, mins, period]).filter(Boolean);

      const wheel = Object.assign(document.createElement('div'), { className: 'kodza-time__wheel' });
      const band = Object.assign(document.createElement('span'), { className: 'kodza-time__band' });
      const colon = Object.assign(document.createElement('span'), { className: 'kodza-time__sep', textContent: sep });
      band.setAttribute('aria-hidden', 'true');
      colon.setAttribute('aria-hidden', 'true');
      wheel.append(band, ...cols.map((c) => c.col));
      hours.col.after(colon);
      const foot = Object.assign(document.createElement('div'), { className: 'kodza-time__foot' });
      const doneButton = Object.assign(document.createElement('button'), { type: 'button', className: 'kodza-time__done', textContent: word('done') });
      foot.append(doneButton);
      pop.append(wheel, foot);

      // Where a line sits when it is on the band, and the line on the band now
      const topOf = (c, i) => c.items[i].offsetTop - (c.list.clientHeight - c.items[i].offsetHeight) / 2;
      const indexAt = (c) => Math.max(0, Math.min(c.items.length - 1, Math.round((c.list.scrollTop - topOf(c, 0)) / c.items[0].offsetHeight)));
      const point = (c, i) => {
        if (c.index === i) return false;
        c.items[c.index]?.setAttribute('aria-selected', 'false');
        c.index = i;
        c.items[i].setAttribute('aria-selected', 'true');
        c.list.setAttribute('aria-activedescendant', c.items[i].id);
        return true;
      };
      // The lens follows its column, in the same frame
      const paint = (c) => (c.reel.style.transform = `translateY(${-c.list.scrollTop}px)`);
      const hourOf = (i, p) => (h12 ? (i % 12) + 12 * p : i);
      // The time the columns say: on the band now (index), or where they are going (to)
      const timeAt = (pick) => hourOf(pick(hours), period ? pick(period) : 0) * 60 + minutes[pick(mins)];
      const timeOf = () => timeAt((c) => c.index);
      const target = () => timeAt((c) => c.to);
      const indicesOf = (t) => {
        const h = Math.floor(t / 60);
        const lines = { hours: h12 ? h % 12 : h, minutes: Math.max(0, minutes.indexOf(t % 60)), period: Math.floor(h / 12) };
        return cols.map((c) => lines[c.name]);
      };
      // What min / max leave out stays on the wheel, faded, and the wheel glides back off it
      const off = (c, i, yes) => {
        c.items[i].classList.toggle('is-off', yes);
        c.ghosts[i].classList.toggle('is-off', yes);
        if (yes) c.items[i].setAttribute('aria-disabled', 'true');
        else c.items[i].removeAttribute('aria-disabled');
      };
      const opens = (h) => minutes.some((m) => valid.has(h * 60 + m));
      const fence = () => {
        const p = period ? period.index : 0;
        hours.items.forEach((_, i) => off(hours, i, !opens(hourOf(i, p))));
        mins.items.forEach((_, i) => off(mins, i, !valid.has(hourOf(hours.index, p) * 60 + minutes[i])));
        period?.items.forEach((_, i) => off(period, i, !hours.items.some((__, j) => opens(hourOf(j, i)))));
      };
      const place = (t) => {
        indicesOf(t).forEach((i, n) => {
          const c = cols[n];
          point(c, i);
          c.to = i;
          c.list.scrollTop = topOf(c, i);
          paint(c);
        });
        fence();
      };

      let gliding = false;
      let dirty = false; // the wheel moved since its time was last taken
      let held = false; // a finger is on the wheel
      const stopGlide = () => {
        cancelAnimationFrame(frame);
        if (!gliding) return;
        gliding = false;
        cols.forEach((c) => c.list.classList.remove('is-gliding'));
      };
      // Sent to a time, the columns glide there together, snapping off on the way
      const glideTo = (targets) => {
        stopGlide();
        clearTimeout(timer);
        dirty = true;
        const runs = [];
        cols.forEach((c, n) => {
          c.to = targets[n];
          const from = c.list.scrollTop;
          const to = topOf(c, targets[n]);
          if (Math.abs(to - from) > 0.5) runs.push({ c, from, to });
        });
        const end = () => {
          gliding = false;
          runs.forEach(({ c, to }) => {
            c.list.scrollTop = to;
            c.list.classList.remove('is-gliding');
            paint(c);
          });
          cols.forEach((c) => point(c, c.to));
          fence();
          settle(true);
        };
        if (!runs.length || still) return end();
        gliding = true;
        runs.forEach(({ c }) => c.list.classList.add('is-gliding'));
        const start = performance.now();
        const duration = GLIDE * pace;
        const tick = (time) => {
          const x = Math.min(1, (time - start) / duration);
          runs.forEach(({ c, from, to }) => {
            c.list.scrollTop = from + (to - from) * easeOut(x);
            paint(c);
          });
          if (x < 1) frame = requestAnimationFrame(tick);
          else end();
        };
        frame = requestAnimationFrame(tick);
      };
      // The wheel has stopped: it takes the nearest time allowed, gliding there first when it is
      // not on it (out of min / max, or left between two lines)
      const settle = (afterGlide = false) => {
        clearTimeout(timer);
        if (gliding || held || !isOpen()) return;
        const goal = nearest(timeOf());
        const targets = indicesOf(goal);
        const away = cols.some((c, n) => c.index !== targets[n] || (!afterGlide && Math.abs(c.list.scrollTop - topOf(c, targets[n])) > 1));
        if (away) return glideTo(targets);
        if (!dirty) return;
        dirty = false;
        commit(goal);
      };
      const arm = () => {
        clearTimeout(timer);
        timer = setTimeout(() => !signal.aborted && settle(), SETTLE);
      };
      const moved = (c) => {
        const i = indexAt(c);
        if (point(c, i)) fence();
        if (gliding) return;
        if (c.to !== i) {
          c.to = i;
          dirty = true;
        }
        if (dirty) arm();
      };
      // One column sent to a line: the wheel glides to the nearest time allowed from there
      const go = (c, i) => glideTo(indicesOf(nearest(timeAt((k) => (k === c ? i : k.to)))));
      // The first line that is not faded, from i on that way, then back the other way
      const reach = (c, i, dir) => {
        const last = c.items.length - 1;
        const from = Math.max(0, Math.min(last, i));
        for (let k = from; k >= 0 && k <= last; k += dir) if (!c.items[k].classList.contains('is-off')) return k;
        for (let k = from - dir; k >= 0 && k <= last; k -= dir) if (!c.items[k].classList.contains('is-off')) return k;
        return c.to;
      };
      const done = () => {
        const t = nearest(target());
        stopGlide();
        clearTimeout(timer);
        dirty = false;
        commit(t);
        close();
      };

      prepare = () => {
        stopGlide();
        dirty = false;
        place(nearest(current ?? now()));
        return cols[0].list;
      };
      flush = () => {
        clearTimeout(timer);
        if (!dirty && !gliding) return;
        const t = nearest(target());
        stopGlide();
        dirty = false;
        commit(t);
      };
      follow = () => {
        if (!isOpen()) return;
        stopGlide();
        dirty = false;
        place(nearest(current ?? now()));
      };

      cols.forEach((c, n) => {
        const { list } = c;
        list.addEventListener('scroll', () => {
          paint(c);
          moved(c);
        }, { passive: true, signal });
        list.addEventListener('scrollend', () => settle(), { signal });
        // A hand on the wheel stops a glide where it is
        list.addEventListener('wheel', stopGlide, { passive: true, signal });
        list.addEventListener('pointerdown', stopGlide, { signal });
        list.addEventListener('touchstart', () => {
          held = true;
          stopGlide();
        }, { passive: true, signal });
        // A click on a line sends the wheel there, a click on the line on the band takes it
        list.addEventListener('click', (event) => {
          const item = event.target.closest('.kodza-time__item');
          if (!item || item.classList.contains('is-off')) return;
          const i = Number(item.dataset.index);
          if (i === c.index && !gliding) return done();
          go(c, i);
        }, { signal });
        list.addEventListener('keydown', (event) => {
          const moves = {
            ArrowUp: [c.to - 1, -1],
            ArrowDown: [c.to + 1, 1],
            PageUp: [c.to - PAGE, -1],
            PageDown: [c.to + PAGE, 1],
            Home: [0, 1],
            End: [c.items.length - 1, -1],
          };
          if (moves[event.key]) {
            event.preventDefault();
            go(c, reach(c, ...moves[event.key]));
          } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
            event.preventDefault();
            cols[n + (event.key === 'ArrowLeft' ? -1 : 1)]?.list.focus({ preventScroll: true });
          } else if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault();
            done();
          }
        }, { signal });
      });
      const release = () => {
        if (!held) return;
        held = false;
        if (dirty) arm();
      };
      document.addEventListener('touchend', release, { signal });
      document.addEventListener('touchcancel', release, { signal });
      doneButton.addEventListener('click', done, { signal });
    } else {
      // The slots of a day: every step from min to max (or DAY_START to DAY_END)
      const start = min ?? toMinutes(DAY_START);
      let end = max ?? toMinutes(DAY_END);
      if (end < start) end += DAY;
      const taken = new Set((root.dataset.taken || '').split(/[\s,]+/).map(toMinutes).filter((t) => t !== null));
      const count = Object.assign(document.createElement('p'), { className: 'kodza-time__count' });
      const list = Object.assign(document.createElement('div'), { className: 'kodza-time__slots' });
      list.setAttribute('role', 'group');
      if (label) list.setAttribute('aria-labelledby', label.id);
      const mark = Object.assign(document.createElement('span'), { className: 'kodza-time__mark is-hidden' });
      mark.setAttribute('aria-hidden', 'true');
      list.append(mark);
      const buttons = [];
      for (let t = start; t <= end; t += step) {
        const time = t % DAY;
        const b = Object.assign(document.createElement('button'), { type: 'button', className: 'kodza-time__slot', textContent: shown.format(at(time)), tabIndex: -1 });
        b.dataset.time = toText(time);
        if (taken.has(time)) {
          b.disabled = true;
          b.setAttribute('aria-label', fill(word('taken'), { time: b.textContent }));
        } else b.setAttribute('aria-pressed', 'false');
        list.append(b);
        buttons.push(b);
      }
      const free = buttons.filter((b) => !b.disabled);
      count.textContent = fill(word('free'), { free: free.length, total: buttons.length });
      pop.append(count, list);

      // The ink mark under the chosen slot: it travels from one to the next, and grows in on the first
      const chosenSlot = () => (current === null ? null : free.find((b) => b.dataset.time === toText(current)) || null);
      const placeMark = (animate) => {
        const chosen = chosenSlot();
        free.forEach((b) => b.setAttribute('aria-pressed', String(b === chosen)));
        if (!chosen) return mark.classList.add('is-hidden');
        const jump = !animate || mark.classList.contains('is-hidden');
        if (jump) mark.style.transition = 'none';
        mark.style.setProperty('--kz-mark-x', `${chosen.offsetLeft}px`);
        mark.style.setProperty('--kz-mark-y', `${chosen.offsetTop}px`);
        mark.style.setProperty('--kz-mark-w', `${chosen.offsetWidth}px`);
        mark.style.setProperty('--kz-mark-h', `${chosen.offsetHeight}px`);
        if (!animate) mark.classList.remove('is-hidden');
        if (jump) {
          void mark.offsetWidth;
          mark.style.transition = '';
        }
        mark.classList.remove('is-hidden');
      };
      // One slot takes the Tab key, the arrows move it; the box scrolls itself (the page never moves)
      let active = null;
      const rove = (b) => {
        if (active) active.tabIndex = -1;
        active = b;
        if (b) b.tabIndex = 0;
      };
      const reveal = (b) => {
        const top = b.offsetTop - 6;
        const bottom = b.offsetTop + b.offsetHeight + 6;
        if (top < list.scrollTop) list.scrollTop = top;
        else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
      };

      prepare = () => {
        placeMark(false);
        const chosen = chosenSlot() || free[0] || null;
        rove(chosen);
        if (chosen) list.scrollTop = chosen.offsetTop - (list.clientHeight - chosen.offsetHeight) / 2;
        return chosen;
      };
      follow = () => placeMark(isOpen());

      list.addEventListener('click', (event) => {
        const b = event.target.closest('.kodza-time__slot');
        if (!b || b.disabled) return;
        rove(b);
        commit(toMinutes(b.dataset.time));
        placeMark(true);
        // Let the mark land, then close
        clearTimeout(timer);
        timer = setTimeout(() => !signal.aborted && close(), still ? 0 : CLOSE_AFTER * pace);
      }, { signal });
      list.addEventListener('keydown', (event) => {
        const b = event.target.closest('.kodza-time__slot');
        if (!b) return;
        const i = free.indexOf(b);
        let next;
        if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') next = free[i + (event.key === 'ArrowLeft' ? -1 : 1)];
        else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
          // The free slot of the nearest row that way, closest to the same column
          const down = event.key === 'ArrowDown';
          const beyond = free.filter((x) => (down ? x.offsetTop > b.offsetTop : x.offsetTop < b.offsetTop));
          const row = Math.min(...beyond.map((x) => Math.abs(x.offsetTop - b.offsetTop)));
          next = beyond
            .filter((x) => Math.abs(x.offsetTop - b.offsetTop) === row)
            .sort((x, y) => Math.abs(x.offsetLeft - b.offsetLeft) - Math.abs(y.offsetLeft - b.offsetLeft))[0];
        } else if (event.key === 'Home') next = free[0];
        else if (event.key === 'End') next = free[free.length - 1];
        else return;
        event.preventDefault();
        if (!next) return;
        rove(next);
        next.focus({ preventScroll: true });
        reveal(next);
      }, { signal });
    }

    const open = () => {
      if (isOpen()) return;
      const focusTarget = prepare();
      // Not enough room below: the panel opens upwards. A closed panel that changes side gets
      // there at once, so it always unrolls from the field
      const box = trigger.getBoundingClientRect();
      const need = pop.offsetHeight + 16;
      const above = box.bottom + need > innerHeight && box.top > need;
      if (above !== root.classList.contains('is-above')) {
        pop.style.transition = 'none';
        root.classList.toggle('is-above', above);
        void pop.offsetWidth;
        pop.style.transition = '';
      }
      root.classList.add('is-open');
      trigger.setAttribute('aria-expanded', 'true');
      requestAnimationFrame(() => isOpen() && focusTarget?.focus({ preventScroll: true }));
    };
    const close = (refocus = true) => {
      if (!isOpen()) return;
      flush();
      clearTimeout(timer);
      root.classList.remove('is-open');
      trigger.setAttribute('aria-expanded', 'false');
      if (refocus) trigger.focus({ preventScroll: true });
    };

    // The input changed by the page (a script that sets .value then fires "change", a form reset)
    const sync = () => {
      if (busy) return;
      current = toMinutes(native.value);
      show(current, true);
      follow();
    };

    trigger.addEventListener('click', () => (isOpen() ? close() : open()), { signal });
    root.addEventListener('keydown', (event) => {
      if (event.key !== 'Escape' || !isOpen()) return;
      event.preventDefault();
      close();
    }, { signal });
    // A click on the label text opens the panel instead of reaching the hidden input
    root.addEventListener('click', (event) => {
      if (trigger.contains(event.target) || pop.contains(event.target)) return;
      event.preventDefault();
      trigger.focus();
    }, { signal });
    pop.addEventListener('click', (event) => event.preventDefault(), { signal });
    document.addEventListener('pointerdown', (event) => !root.contains(event.target) && close(false), { signal });
    // Escape closes it even if the focus wandered off to the page
    document.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && isOpen() && !root.contains(document.activeElement)) close();
    }, { signal });
    root.addEventListener('focusout', (event) => {
      if (isOpen() && event.relatedTarget && !root.contains(event.relatedTarget)) close(false);
    }, { signal });
    native.addEventListener('change', sync, { signal });
    native.form?.addEventListener('reset', () => setTimeout(() => !signal.aborted && sync()), { signal });
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      cancelAnimationFrame(frame);
    });

    show(current, false);
    pace = paceOf(root);
    still = reduceMotion || !pace;
  }

  // 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).
