Time Picker in React: 12/24h, AM/PM, Keyboard Navigation
Build a fully accessible React time picker with 12/24-hour toggle, AM/PM support, and full keyboard navigation — no bloated libs, just clean Tailwind components.
Why Time Pickers Are Harder Than They Look
Honestly, every developer underestimates the time picker. You think: it's just two number inputs and maybe a toggle. Then you ship it and immediately get bug reports — the AM/PM flip breaks on midnight, tab order skips the meridiem selector, mobile keyboards pop up unexpectedly, and the 24-hour format shows "00" where users expected "12". That's before you even touch internationalization.
The native <input type="time"> solves *some* of these problems, but its browser-rendered UI is notoriously inconsistent. Chrome on Windows looks fine. Safari on iOS renders it as a three-column scroll drum that fights your design system. Firefox on Linux shows a plain text field. If your app targets multiple platforms — and yours almost certainly does — you need a custom implementation.
This guide walks through building a production-ready time picker component in React with Tailwind CSS. We'll cover state management for both 12-hour and 24-hour modes, proper keyboard navigation following ARIA patterns, and AM/PM toggling without re-mounting the component.
State Design: 12-Hour vs 24-Hour Mode
The core decision is whether to store time internally as 24-hour values and convert for display, or to store the meridiem separately. Store 24-hour internally. Always. It makes serialization, comparison, and validation trivially simple — you're just working with hours 0–23 and minutes 0–59. Everything else is a display concern.
Your component's state needs three things: hours (0–23), minutes (0–59), and mode ("12h" or "24h"). When mode is "12h", derive the displayed hour by converting: hours 0 and 12 both display as "12", hours 1–11 display as-is, and hours 13–23 display as hour - 12. The meridiem (AM/PM) is then just hours < 12 ? 'AM' : 'PM'.
Flipping between modes should be a display-only change. If a user sets 2:30 PM in 12h mode and you switch to 24h, you should show 14:30 — not reset the time. This is the number one UX failure in third-party time picker libraries.
Building the Core TimePicker Component
Here's a minimal but production-ready time picker. It uses Tailwind v4.0.2 utility classes, handles both modes, and exposes an onChange callback with an ISO-compatible HH:MM string.
// TimePicker.tsx
import { useState, useRef, KeyboardEvent } from 'react';
interface TimePickerProps {
defaultValue?: string; // "HH:MM" 24h format
mode?: '12h' | '24h';
onChange?: (value: string) => void;
}
export function TimePicker({
defaultValue = '09:00',
mode = '12h',
onChange,
}: TimePickerProps) {
const [h, m] = defaultValue.split(':').map(Number);
const [hours, setHours] = useState(h);
const [minutes, setMinutes] = useState(m);
const [displayMode, setDisplayMode] = useState<'12h' | '24h'>(mode);
const hourRef = useRef<HTMLButtonElement>(null);
const meridiem = hours < 12 ? 'AM' : 'PM';
const displayHour =
displayMode === '24h'
? String(hours).padStart(2, '0')
: String(hours % 12 === 0 ? 12 : hours % 12).padStart(2, '0');
const displayMinute = String(minutes).padStart(2, '0');
const emit = (nextH: number, nextM: number) => {
const val = `${String(nextH).padStart(2, '0')}:${String(nextM).padStart(2, '0')}`;
onChange?.(val);
};
const stepHour = (dir: 1 | -1) => {
const next = (hours + dir + 24) % 24;
setHours(next);
emit(next, minutes);
};
const stepMinute = (dir: 1 | -1) => {
const next = (minutes + dir + 60) % 60;
setMinutes(next);
emit(hours, next);
};
const toggleMeridiem = () => {
const next = (hours + 12) % 24;
setHours(next);
emit(next, minutes);
};
const handleHourKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp') { e.preventDefault(); stepHour(1); }
if (e.key === 'ArrowDown') { e.preventDefault(); stepHour(-1); }
};
const handleMinuteKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp') { e.preventDefault(); stepMinute(1); }
if (e.key === 'ArrowDown') { e.preventDefault(); stepMinute(-1); }
};
return (
<div
role="group"
aria-label="Time picker"
className="inline-flex items-center gap-1 rounded-xl border border-white/20 bg-white/10 backdrop-blur-md px-4 py-3 text-white shadow-lg"
>
{/* Hour */}
<button
ref={hourRef}
role="spinbutton"
aria-label="Hours"
aria-valuenow={Number(displayHour)}
aria-valuemin={displayMode === '24h' ? 0 : 1}
aria-valuemax={displayMode === '24h' ? 23 : 12}
tabIndex={0}
onKeyDown={handleHourKey}
onClick={() => stepHour(1)}
className="w-10 rounded-lg py-1 text-center text-2xl font-mono font-semibold
hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/40"
>
{displayHour}
</button>
<span className="text-2xl font-mono font-bold opacity-70 select-none">:</span>
{/* Minute */}
<button
role="spinbutton"
aria-label="Minutes"
aria-valuenow={minutes}
aria-valuemin={0}
aria-valuemax={59}
tabIndex={0}
onKeyDown={handleMinuteKey}
onClick={() => stepMinute(1)}
className="w-10 rounded-lg py-1 text-center text-2xl font-mono font-semibold
hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/40"
>
{displayMinute}
</button>
{/* AM / PM toggle — hidden in 24h mode */}
{displayMode === '12h' && (
<button
aria-label={`Switch to ${meridiem === 'AM' ? 'PM' : 'AM'}`}
onClick={toggleMeridiem}
className="ml-2 rounded-lg px-2 py-1 text-sm font-semibold uppercase
bg-white/20 hover:bg-white/30 focus:outline-none focus:ring-2 focus:ring-white/40"
>
{meridiem}
</button>
)}
{/* Mode toggle */}
<button
aria-label={`Switch to ${displayMode === '12h' ? '24-hour' : '12-hour'} format`}
onClick={() => setDisplayMode(displayMode === '12h' ? '24h' : '12h')}
className="ml-3 rounded-md px-2 py-0.5 text-xs opacity-60 hover:opacity-100
border border-white/20 hover:border-white/40"
>
{displayMode}
</button>
</div>
);
}A few things worth calling out. The role="spinbutton" on each segment is the correct ARIA pattern for a numeric input that increments and wraps — screen readers announce it differently from a plain button. The aria-valuenow, aria-valuemin, and aria-valuemax attributes give assistive technology the context it needs to say "9 hours" rather than just reading the displayed text "09". And note the wrapping arithmetic — (hours + dir + 24) % 24 handles the midnight-to-11pm wrap without a conditional.
The glassmorphism styling (bg-white/10 backdrop-blur-md border border-white/20) is optional but worth keeping if you want it to slot into any dark gradient background out of the box. You can strip those back to solid neutrals if you're working in a light-mode design system — the logic is completely independent of the visual layer. If you're exploring component styles across the library, the animated tabs component uses a similar slot-selection pattern that pairs well with a time picker in booking UIs.
Keyboard Navigation That Actually Works
The ARIA spinbutton pattern specifies that ArrowUp increments and ArrowDown decrements. That's what we implemented above. But what about jumping by larger steps? A common UX refinement is PageUp/PageDown to step by 15-minute intervals in the minute field, and Home/End to jump to 0 or 59.
// Enhanced minute keyboard handler
const handleMinuteKey = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp': e.preventDefault(); stepMinute(1); break;
case 'ArrowDown': e.preventDefault(); stepMinute(-1); break;
case 'PageUp': e.preventDefault(); stepMinute(15); break;
case 'PageDown': e.preventDefault(); stepMinute(-15); break;
case 'Home': e.preventDefault(); setMinutes(0); emit(hours, 0); break;
case 'End': e.preventDefault(); setMinutes(59); emit(hours, 59); break;
}
};Tab order matters too. The natural DOM order — hour button, minute button, AM/PM button, mode toggle — already gives you a logical tab sequence. Don't override tabIndex beyond 0 on these elements or you'll create traps for keyboard users. If you embed the picker inside a popover or modal, make sure the popover traps focus correctly when open and returns focus to the trigger when it closes. That's the part most implementations get wrong.
Controlled vs Uncontrolled Time Picker Patterns
The component above is uncontrolled — it manages its own state and fires onChange as a side effect. That works for most form scenarios, but if you're integrating with React Hook Form, Zod validation, or a form library that uses register(), you need a controlled version where value is passed in as a prop and the component calls onChange instead of managing state internally.
The cleanest approach is to split the component into a useTimePicker hook that holds all the state logic, and a TimePicker component that's a thin rendering layer. The hook accepts an optional value prop — when it's present, the hook syncs its internal state on prop changes via a useEffect. When it's absent, the hook owns the state entirely. This pattern is also what React's own <input> uses under the hood.
What about minutes that snap to 5 or 15? Add a minuteStep prop (default 1) and replace the stepMinute logic with Math.round((minutes + dir * step) / step) * step clamped to [0, 59]. This is the same snapping behavior you see in Google Calendar and most booking platforms. Is this overkill for a simple schedule form? Probably. But for any calendar or appointment UI, it saves a lot of support tickets from users who tried to book at 11:47 AM.
Styling the Time Picker With Tailwind v4
With Tailwind v4.0.2, the @utility and @variant APIs make it easy to extract component styles without abandoning utility classes. If your time picker is used across your entire app, you don't want to copy that massive className string everywhere. Pull it into a local utility.
/* In your global CSS, using Tailwind v4 @utility */
@utility time-segment {
@apply w-10 rounded-lg py-1 text-center text-2xl font-mono font-semibold
hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/40
transition-colors duration-150;
}
@utility time-picker-root {
@apply inline-flex items-center gap-1 rounded-xl
border border-white/20 bg-white/10 backdrop-blur-md
px-4 py-3 text-white shadow-lg;
}One thing to watch: the gap-1 (4px) between the colon and the hour/minute segments can feel tight on mobile. Bump it to gap-2 (8px) if your picker lives in a form where the user is tapping with a finger rather than clicking with a mouse. That 4px difference sounds trivial — it's not when you're on a phone with slightly imprecise tap targets.
For dark mode integration, if you're using theme-toggle-react patterns, swap bg-white/10 for a CSS custom property: background: rgba(var(--surface-rgb), 0.1) so the picker adapts without a hard class swap. Tailwind v4's CSS variable support makes this pattern first-class rather than a workaround.
Integrating with Forms and Validation
Time pickers in forms need to serialize cleanly. The HH:MM 24-hour string format ("14:30") is the safest choice — it's what HTML's <input type="time"> returns, it parses into JavaScript's Date objects without ambiguity, and Zod's z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/) validates it in one line.
For React Hook Form, wrap the component in a Controller: pass field.value as the time picker's value prop and field.onChange as the onChange callback. The HH:MM string you're emitting is already the right shape for RHF's field value. No conversion needed.
One common gotcha: if your form submits to a backend that stores UTC timestamps, you need to combine the time string with a date and apply the user's timezone offset before sending. A HH:MM string is timezone-naive by definition. Don't let that slip through to your API — it will cause off-by-one-hour bugs that only appear during DST transitions and are miserable to debug. Pair your time picker with a date picker and run new Date(dateStr + 'T' + timeStr).toISOString() before submission.
Time Picker in Context: Booking and Scheduling UIs
The time picker rarely lives alone. In booking flows it sits next to a date picker, a duration selector, and probably a timezone dropdown. The layout challenge is keeping all three inputs visually cohesive without burning a ton of horizontal space. A good pattern is to stack them in a card with an 8px vertical gap between each field, using a muted label above each input so users always know which field they're editing.
If you're building a multi-step booking flow, consider pairing your time picker with an animated tabs component to separate date selection, time selection, and confirmation into distinct steps. This reduces cognitive load compared to showing everything at once, and the tab transition gives users clear feedback that they're progressing through the form.
For a more visually dense scheduling dashboard — think a weekly calendar view — a time picker in a popover triggered by clicking a time slot works better than an always-visible control. The popover should open at the cursor position, pre-populate with the clicked slot's time, and close on Escape or outside click. That's three more things to get right, but each is a solved problem with standard React patterns. Empire UI's cards stack component shows similar overlay/popover positioning techniques that transfer directly to this use case.
FAQ
It depends on your design requirements. The native input is zero-effort and fully accessible, but its rendered UI varies wildly across browsers and operating systems — especially on iOS Safari, which forces a scroll-drum picker. If you need consistent cross-platform styling that matches your design system, a custom component is worth the effort. If your app is internal tooling where the native look is acceptable, just use the native input.
Always store hours as a 24-hour integer (0–23) and minutes as 0–59 internally. Convert to 12-hour display format only when rendering. This avoids ambiguity around midnight (00:00 vs 12:00 AM) and makes it easy to serialize to ISO strings without conversion.
Each segment (hours, minutes) should use role="spinbutton" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-label attributes. The AM/PM toggle is a regular button with a descriptive aria-label. Wrap the whole control in a role="group" with aria-label="Time picker" so screen readers announce it as a single logical unit.
Use the Controller component from React Hook Form. Pass field.value as the value prop to your time picker and field.onChange as the onChange callback. Make sure your time picker emits a HH:MM 24-hour string so the field value is always in a consistent, serializable format.
A time picker only captures a local time string like "14:30" — it's timezone-naive. If your backend expects UTC timestamps, combine the time string with a date string and apply the user's timezone before submission: new Date(dateString + 'T' + timeString).toISOString(). Never send a bare HH:MM string to a timestamp column.
Yes. Add a minuteStep prop (e.g., 5 or 15) and change the step logic to Math.round((minutes + dir * step) / step) * step, clamped between 0 and 59. This snapping pattern is standard in calendar and booking UIs where free-form minute selection is rarely useful.