PickTime v3
One custom element. No framework wrapper, no stylesheet to import, no hidden input to keep in sync.
<form>
<label for="meeting">Meeting time</label>
<pick-time id="meeting" name="meeting" value="09:30"></pick-time>
<button>Book</button>
</form>
<script type="module">
import 'picktime';
form.addEventListener('submit', e => {
new FormData(form).get('meeting'); // "09:30"
});
</script>
01 / install
One dependency, one import. That import registers
<pick-time> and carries its styles with it.
npm install picktime
import 'picktime';
<pick-time name="start"></pick-time>
Chrome 114+, Edge 114+, Firefox 125+, Safari 17+. No build step required for the CDN route.
02 / how to use
It is a custom element, so there is nothing to wrap. Pick your stack and copy the block.
<script type="module" src="https://cdn.jsdelivr.net/npm/picktime/+esm"></script>
<form>
<label for="start">Start</label>
<pick-time id="start" name="start" value="09:30"></pick-time>
<button>Submit</button>
</form>
Nothing else. The form submits start=09:30.
import 'picktime';
import { useState } from 'react';
export function Booking() {
const [time, setTime] = useState('09:30');
return (
<pick-time
name="start"
value={time}
min="09:00"
max="17:00"
onChange={e => setTime(e.target.value)}
/>
);
}
React 19+ passes props and listeners to custom elements natively.
On React 18 use a ref to set value and
attach the change listener.
// vite.config.js
vue({
template: {
compilerOptions: { isCustomElement: tag => tag === 'pick-time' }
}
})
<script setup>
import 'picktime';
import { ref } from 'vue';
const time = ref('09:30');
</script>
<template>
<pick-time name="start" :value="time"
@change="time = $event.target.value" />
</template>
<script>
import 'picktime';
let time = $state('09:30');
</script>
<pick-time
name="start"
value={time}
onchange={e => (time = e.target.value)}
/>
No configuration needed.
import 'picktime';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<pick-time name="start" [value]="time"
(change)="time = $any($event.target).value">
</pick-time>
`
})
export class BookingComponent { time = '09:30'; }
03 / playground
Change an attribute, watch the element react. Nothing is faked.
04 / the point
Form-associated through ElementInternals. No hidden
input, no serialiser, no submit handler doing cleanup.
required, min and max block
submission the way the platform does it.
Submit to see the FormData payload.
05 / accessibility
Each segment is an ARIA spinbutton that announces its value and range. Tab is trapped inside the panel while it is open, and focus returns to the field when it closes.
Tab cycles hours, minutes, seconds, AM, PM, then wraps. It never escapes to the page behind.
06 / input
The field is not readonly. Parsing is lenient, and
anything unparseable sets badInput rather than throwing.
9:30 pm21:30930pm21:30093009:3021:3021:309.3009:3012am00:0007 / i18n
Hour cycle, day-period labels and formatting all come from
Intl. Omit hour-cycle and each locale gets
what it expects.
08 / rendering
The panel is a real popover, so it renders in the top layer. An
overflow: hidden ancestor and a stacking context both
lose. Escape and click-outside come free with it.
09 / theming
Styles live in a shadow root, so your CSS cannot break it and it cannot leak into you. Thirty-six custom properties are the seams, and they are the same names v2 used, so an existing theme ports across by moving it from a class onto the element.
Click the field
pick-time.kd {
/* Main */
--pt-background: #274bce;
--pt-foreground: #fff;
/* Panel */
--pt-padding: 10px;
--pt-border-radius: 12px;
--pt-border-color: #1b338c;
--pt-box-shadow-color: rgb(0 0 0 / 0.2);
/* Segments */
--pt-input-color: var(--pt-foreground);
--pt-input-background: var(--pt-background);
--pt-input-font-size: 30px;
--pt-input-border-radius: 10px;
--pt-input-border-color: #1b338c;
--pt-input-focus-border-color: var(--pt-foreground);
--pt-input-focus-background: #213fad;
/* Dots */
--pt-dots-color: #1b338c;
/* Meridiem */
--pt-meridiem-font-size: 10px;
--pt-meridiem-border-radius: 50%;
--pt-meridiem-color: var(--pt-foreground);
--pt-meridiem-hover-color: var(--pt-foreground);
--pt-meridiem-hover-background: #213fad;
--pt-meridiem-border-color: var(--pt-input-border-color);
--pt-meridiem-checked-color: var(--pt-foreground);
--pt-meridiem-checked-background: #ff6e46;
--pt-meridiem-checked-border-color: var(--pt-foreground);
/* Trigger, kept neutral so only the panel carries the theme */
--pt-field-background: #fff;
--pt-field-color: #1b338c;
--pt-field-border-color: #cbd6f5;
--pt-field-border-radius: 10px;
}
Colour and spacing come from the properties above. When you need to reach a specific piece of the structure, target its part:
::part(input)::part(picker)::part(spin)::part(hours)::part(minutes)::part(seconds)::part(separator)::part(meridiem)::part(period)::part(arrow)pick-time::part(picker) {
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
}
pick-time::part(hours) {
font-variant-numeric: tabular-nums slashed-zero;
}