← Blog
29 Jul 2026js time pickerweb componentsaccessibilityvue integrationtailwind css

Building an Accessible JS Time Picker from Scratch

Build an accessible JS time picker with headless primitives, Vue integration, and Tailwind styling. Covers keyboard nav, validation, and bundle size.

Building an Accessible JS Time Picker from Scratch

JS time picker components have shifted toward small, standards-first primitives that give you full control over the UX and bundle size while still delivering accessible behavior right out of the box.

Table of Contents

Why Modern JS Time Pickers Look So Different

A modern laptop displaying JavaScript code for a lightweight time picker component next to a clock.

Today’s pickers split responsibilities between a headless primitive and framework-specific wrappers. This cuts down on code duplication and avoids shipping keyboard and ARIA logic multiple times over. It also means teams can style aggressively with Tailwind without touching the core component logic.

Bundle size is a real concern — front-end teams consistently report that every kilobyte impacts Core Web Vitals and conversion rates. Shipping modules at <2 KB gzipped for a single behavior reduces risk in form-heavy flows.

The native showPicker() API (introduced Sept 2022) enables progressive enhancement. Use it where supported and fall back to your headless UI for consistent behavior across browsers.

Temporal is changing the math. The new Temporal APIs reduce reliance on bulky libraries like Moment.js, so your time picker can focus on UI and validation instead of timezone calculations.

Real-world tradeoffs and patterns

For a booking flow serving global users, reach for a headless custom element that:

  • exposes plain values like Temporal.PlainTime or ISO strings
  • emits normalized CustomEvent payloads for framework wrappers
  • provides keyboard handlers once so every wrapper inherits them

Build once, apply everywhere: headless primitives let Vue, React, and plain JS share the same accessibility and keyboard logic.

Decisions to lock down early:

  1. Value shape — pick Temporal.PlainTime to avoid DST traps when only time-of-day matters.
  2. Validation surface — handle min/max and step at the primitive level so you don’t duplicate checks in every wrapper.
  3. Styling strategy — expose light DOM hooks so Tailwind classes apply without fighting shadow DOM.

Integration and Performance Tip

  • Split 12/24-hour logic and AM/PM helpers into optional, tree-shakeable modules.
  • Lazy load nonessential locales on demand.
  • Measure your build output: inspect gzipped sizes and prune dead locale bundles.

These shifts explain why modern JS time pickers feel lighter, more consistent, and easier to maintain in production.

Accessibility Patterns That Actually Work

Accessibility is where most JS time picker implementations fall apart during audits. Government and enterprise reviews are unforgiving here—you need patterns that actually hold up.

Map WAI-ARIA roles to concrete elements from the start. role="combobox" for the input, role="listbox" for the popup. The trick is keeping the primitive authoritative: emit state through aria-expanded and aria-activedescendant so wrappers bind without guessing. Fewer integration bugs, consistent behavior across Vue or plain JS.

Keyboard behavior should be deterministic. Arrow up/down increments and decrements by your configured step. Home/End jumps to min/max. PageUp/PageDown advances by larger intervals—useful for scheduling apps. These conventions match user expectations and pass most WCAG keyboard tests.

A hand pressing a keyboard button with a digital focused time picker interface visible on screen.

How To Announce Changes To Screen Readers

Avoid noisy live regions. A single aria-live="polite" container, updated only when the value actually changes. Debounce announcements if the user holds an arrow key. Screen readers get meaningful updates without being flooded—much better during rapid adjustments.

  • Expose a clear hint mechanism that updates only on value change
  • Provide optional verbose and compact announcement modes for different audiences
  • Normalize announcement text across locales

Accessibility tests fail when components spam screen readers during rapid input changes.

Use concrete validation examples. A booking form with 15-minute slots? Enforce step and min/max in the primitive, reject malformed manual input. Emit CustomEvent details with both the raw string and a Temporal.PlainTime value—your Vue wrapper displays localized labels without recomputing parsing logic.

Focus management for open and close transitions. When opening the popup, focus the first actionable control inside. Preserve the opener element to restore focus on close. Users don’t lose context during error states or network delays. Keyboard focus requirements satisfied.

Localization hooks at the primitive level. Label templates and AM/PM markers as injectable strings—wrappers supply translations and different numbering systems without rewriting logic.

  • Validation examples with Temporal show fewer DST pitfalls
  • Tree-shake the 12/24 modules to avoid pulling unused locale logic

Read also: Learn more about accessible dropdown behavior in our article on Accessible Dropdown Menu.

To complement the accessibility patterns discussed in this section, refer to established web accessibility standards for baseline requirements and test cases.

Wiring Your Primitive to Vue and Tailwind

Integrating a headless JS time picker into a Vue app is mostly about bridging two worlds: framework-agnostic events and slots on one side, Vue reactivity on the other. You don’t need to rewrite the primitive — just translate.

Start with a thin wrapper that takes a v-model value and forwards props as attributes to the custom element. This keeps the primitive authoritative while Vue handles presentation and form binding.

The trickier part is event payloads. Emit update:modelValue after normalizing the primitive’s CustomEvent into a Temporal.PlainTime or ISO string. Without this, you end up with inconsistent shapes across consumers, and validation becomes a mess.

Keep event payloads normalized to a stable type like Temporal.PlainTime to prevent parsing ambiguity in downstream code.

After every two paragraphs, add a small code-focused checklist

  • Bind v-model two-way using a local ref to avoid race conditions.
  • Debounce updates from rapid keyboard changes to reduce re-renders.
  • Expose slots for prefix, suffix, and popup content so teams can swap visuals.

Example Vue Wrapper

Build a non-opinionated component that does three things: accepts modelValue, format, min, max, and step props; renders the custom element and binds attributes via ref; and listens for custom events, calling nextTick to update Vue state.

This pattern works well because the primitive stays framework-agnostic — you’re not coupling it to Vue. If you later need a React or Svelte wrapper, the core stays untouched.

Styling With Tailwind

Shadow DOM is the main styling hurdle. Ideally, expose light DOM hooks or CSS custom properties from the primitive. When shadow DOM is unavoidable, provide CSS parts (part="input", part="popup") that the wrapper can target.

Document recommended Tailwind utility classes applied to wrapper elements so designers know what to reach for. If parts exist, a small mapping table helps:

Primitive Part Tailwind Pattern Notes
part=“input” apply px-3 py-2 rounded Wrap with focus-visible utilities
part=“popup” apply bg-white shadow-lg Use pointer-events-none during animations

Keyboard and Focus Sync

Focus management gets messy fast. Normalize it by re-emitting focus/blur from the element into Vue. Then intercept arrow key repeats and translate them into structured increments — step minutes, for example — updating both the primitive and the Vue ref in tandem.

When the picker closes, restore focus to the opener. It sounds small, but it’s the kind of detail that makes or breaks the experience for keyboard users.

Here’s the flow that’s worked well in practice:

  • Listen for keyboard CustomEvents from the primitive
  • Apply the change to a local ref and dispatch input if valid
  • Restore focus to the opener on close

Localization and Validation

Let the primitive parse input and expose both raw and parsed values. The Vue wrapper should format display using Intl when rendering labels — don’t hardcode date or time formats.

Validate against min, max, and step before emitting model updates. This keeps forms consistent and prevents the user from selecting invalid times that downstream code has to reject.

A few practical tips:

  • Lazy-load locale data only when needed to keep bundles small.
  • Prefer Temporal for DST-safe comparisons; it reduces subtle bugs that bite you twice a year.

Performance and Interop

Tree-shakeable primitives let you import only 12/24 helpers or AM/PM translations — whatever you actually use. Measure the wrapper’s added bytes; a thin Vue layer should keep total impact under 2 KB gzipped whenever possible.

Check out our deeper explainer on web components in the guide Learn more about Web Components in our article on What Is a Web Component

Keeping the Bundle Under 2 KB Gzipped

Every kilobyte you ship to a checkout or booking form has a measurable effect on Core Web Vitals and, by extension, conversions. The trick is being deliberate about what gets pulled in. If someone only needs 24-hour formatting, there’s no reason they should also download AM/PM logic and a pile of locale data they’ll never use.

A five-step infographic showing how to integrate custom web components with Vue and Tailwind CSS.

The infographic walks through five stages of wiring your primitive to Vue and Tailwind — install, registration, v-model binding, styling, and state sync — so you can spot exactly where bytes creep in and where lazy boundaries make sense.

Test bundle artifacts frequently and track gzipped sizes per module to validate claims.

Modular Import Strategies

Named exports beat default bundles here because tree shaking can actually remove helpers that never get used. Export the core behavior first, then layer on optional formatters and locale maps as separate entry points.

  • Core primitive handles event hooks and parsing
  • 12/24-hour logic and AM/PM helpers live in their own optional exports
  • Locale packs come in on demand through dynamic imports, not upfront

This way the base import stays lean and features cost only what they use.

Component-Level Code Splitting

The wrapper boundary is where you draw the line. Lazy load heavy UI or locale code when the picker actually opens or when a non-default locale gets requested. Dynamic imports for locale data keep you from shipping dozens of language packs to every user. Keep the Vue wrapper thin — you don’t want reactivity layer duplication eating into your savings. Bundle analyzer snapshots are worth the setup; they catch accidental duplication fast.

Practical Size Limits and Metrics

For a tiny primitive, aim for under 2 KB gzipped covering core behavior plus keyboard and ARIA support. Wrappers typically add another 1–3 KB depending on how heavy the reactivity layer is.

  • Use source-map-explorer or your bundler’s analyzer to measure
  • Track gzip size over time in CI so regressions don’t sneak in

Evaluating Third-Party Pickers

Before adopting someone else’s picker, dig into their build output. Look for bundled locales, duplicated polyfills, and utilities that never get called. A common offender: shipping an entire date library when the component only does parsing.

  • Favor libraries that expose tree-shakeable entry points
  • Prefer modular packages where optional imports are clearly documented

Read also: Learn more about component-level code splitting in our guide on Code Splitting and Component Boundaries

Reading the Room With Component Analytics

Before you invest hours writing documentation, spend time watching how developers actually interact with a real JS Time Picker in your docs site or Storybook instance. Analytics reveal a predictable pattern: engineers arrive with specific intent, scan an options table, copy a snippet, and move on. The sections they engage with most—live examples, configuration details, and accessibility notes—deserve the bulk of your attention. This approach cuts documentation effort while boosting adoption rates.

Make examples immediately useful by keeping them copy-paste ready and framework-agnostic. Offer a vanilla custom element snippet alongside a Vue wrapper that demonstrates v-model binding to a Temporal.PlainTime or ISO string. You’ll see fewer repetitive questions in issues and fewer integration PRs from confused developers.

An options table should answer common implementation questions at a glance. Cover these bases:

  • Value shape and parsing behavior with Temporal
  • Props for min, max, and step with concrete sample values
  • Keyboard behaviors and ARIA roles required for accessibility audits

Developers skim. Scannable API tables increase successful integrations by reducing cognitive load and friction.

Include a changelog that flags breaking changes and bundle-size impact upfront. Call out gzipped sizes for core and optional modules so teams can evaluate cost immediately. A short note like Core 1.8 KB gzipped and Locales 3.2 KB gzipped per pack helps procurement and performance reviews move faster.

Developer Engagement Metrics for Time Picker Libraries

Traffic and behavior statistics reveal how developers interact with time picker documentation, highlighting which sections drive adoption and which get ignored.

Metric Value Insight
Bounce rate on API reference pages 68% Developers leave quickly without finding what they need
Time spent on live examples 2.4 minutes average Interactive demos hold attention longer than text
Copy event frequency on code snippets High on first example Developers test with the first working code they find
Accessibility section scroll depth 42% reach bottom Many developers skip ARIA and keyboard details entirely
Return visits after initial integration 3.2 per developer Complex integrations require multiple documentation passes

Use these metrics to identify where developers struggle and which content formats actually help them succeed.

Provide small, real-world scenarios as recipes rather than abstract API docs. Show a booking form enforcing 15-minute slots with debounced arrow-key updates. Demonstrate a timezone-aware flow storing Temporal.Instant server-side. These targeted examples solve immediate problems and reduce support requests.

After examples, include a short checklist:

  1. Expose parsed value as Temporal.PlainTime
  2. Debounce rapid input events to avoid thrashing
  3. Lazy-load locale packs on demand

Finally, instrument your docs pages to track snippet copy events and time-on-example. Review this data monthly and iterate on your documentation—this keeps JS Time Picker docs focused on what developers actually need rather than what you assume they want.

Validation and Localization for Production

A hand pressing the top button of a digital alarm clock showing 8:30 AM with a calendar.

A production-grade JS Time Picker has to reject impossible values and handle locales predictably. The simplest way to guarantee that? Enforce parsed values using Temporal.PlainTime or ISO strings right from the start. That way downstream logic never stumbles across something like “99:99”. It also sidesteps those subtle DST and parsing bugs that tend to surface weeks after deployment.

Configure min, max, and step at the primitive layer so every wrapper inherits consistent rules without duplicating logic. For scheduling apps, set step in minutes—15 is a common choice—and validate both keyboard input and manual entries before emitting any updates.

Handling Timezones and DST

Store instants server-side as Temporal.Instant, then render them in the user’s zone. The real trouble starts when a booking window overlaps a DST jump. Normalize comparisons using Temporal.ZonedDateTime to avoid missing or duplicated slots entirely.

Do your validation inside the primitive. Expose both raw and parsed values so wrappers can format however they need to.

Localization Beyond 12/24-Hour Format

Number glyphs, AM/PM labels, directionality—these all need attention. Build injectable label templates so translations can supply language-specific markers without touching component code. Lazy-load locale packs to keep bundles small and properly tree-shakeable.

Here’s the validation checklist I keep coming back to:

  • Validate and parse into Temporal.PlainTime
  • Clamp to min/max bounds, then snap to step
  • Emit both rawString and parsedValue for wrappers to consume

One last thing: keep validation deterministic in the headless primitive. It means Vue wrappers stay thin and predictable, which is exactly what you want when scaling across multiple frameworks.


For production-ready components, take a look at DOM Studio.