A flexible time input for shadcn/ui projects. Supports 24h and 12h formats, AM/PM toggle, optional seconds, and auto zero-padding on blur — everything the native type="time"can't do.
Why time-input?
The native <input type="time"> is nearly impossible to style, behaves inconsistently across browsers and operating systems, and exposes no JavaScript API for formatting. It auto-advances focus differently in Chrome versus Firefox, renders a browser-native picker that ignores your design tokens, and provides no mechanism for rounding, zero-filling, or overflow hours.
Existing third-party time pickers solve some of these problems but introduce their own design systems — requiring overrides to match shadcn/ui tokens — and none handle the full-width digit output produced by CJK input methods. This component installs as a single file via the shadcn registry and uses only the CSS variables already in your project.
Zero-pad on blur
When a user types 9 and tabs away the component emits 09:00, not a bare 9. The native input does not do this. Neither does any commonly-used React time picker.
Minute rounding
Rounds to the nearest configurable interval on blur — with three modes: nearest, floor, and ceil. Day-boundary awareness prevents rolling past 23:55 when roundLastIntervalDown is set, so scheduling UIs never accidentally advance to the next calendar day.
CJK input method normalization
Japanese, Chinese, and Korean IMEs routinely emit full-width digits (1, 2, …) instead of ASCII digits. The component normalizes all input through NFKC before extracting digits, so users typing in their native input method see the same seamless experience as those on a US keyboard.
Overflow hours
Values like 27:00 — common in broadcast scheduling, shift planning, and next-day time tracking — are fully supported. maxOverflowHours caps the range, and rounding is overflow-aware so it will not accidentally roll a 27-hour value back to 00:00.
shadcn/ui native
Distributed through the shadcn registry as a single source file. It reads --border, --input, --ring, and the rest of your existing CSS variables with no additional configuration. Dark mode, radius, and brand colors are inherited automatically.
Consistent 24h output
The value / onChange contract is always HH:mm or HH:mm:ss in 24-hour format, regardless of whether 12h display is active. Forms, validation libraries, and server handlers receive a predictable string and never need to parse AM/PM.
117 tests, 0 surprises
117 tests across 23 describe blocks cover every behavior: auto-advance typing, blur padding, rounding, overflow, CJK normalization, AM/PM toggling, keyboard navigation, controlled sync, and scroll-to-step. Contributions and upgrades stay safe.
How CJK normalization works
Unicode's "Halfwidth and Fullwidth Forms" block (U+FF00–U+FFEF) contains full-width variants of every ASCII character. Japanese IMEs use these when the user is in full-width input mode — the common default for number entry on many systems. The digits 0123456789 (U+FF10–U+FF19) look like regular numbers but are distinct code points that most inputs silently reject or mishandle.
NFKC (Compatibility Decomposition followed by Canonical Composition) is one of four Unicode normalization forms. It maps compatibility characters to their canonical equivalents — full-width digits become ASCII digits, superscripts like ¹² (U+00B9, U+00B2) become 12, and ligatures like fi become fi. The internal getDigits helper runs every keystroke's value through this transform before stripping non-digits:
function getDigits(raw: string) {
return raw
.normalize("NFKC") // "14" → "14", "¹²" → "12"
.replace(/\D/g, "") // strip anything that isn't 0–9
.slice(-2) // keep last 2 digits (handles IME composing state)
}The .slice(-2) is specifically important for IME composing state: while an IME is mid-composition the input's value may temporarily contain the partially-composed string concatenated with the prior value. Taking the last two characters ensures the committed digit always wins without needing to track composing events explicitly.
| Feature | type="time" | Other pickers | time-input |
|---|---|---|---|
| shadcn/ui tokens | No | No | Yes |
| Zero-pad on blur | No | varies | Yes |
| Minute rounding | No | No | Yes |
| CJK normalization | No | No | Yes |
| Overflow hours | No | No | Yes |
| 12h + 24h display | system | varies | Yes |
| Consistent 24h output | No | varies | Yes |
| Single-file install | No | No | Yes |
| No extra dependencies | Yes | No | Yes |
| Test coverage | No | varies | Yes |
Install
Requires an existing shadcn/ui project (npx shadcn@latest init). After installing, import from @/components/ui/time-input.
Live demo
24-hour
value: 14:05
12-hour
value: 14:30
With seconds
value: 09:45:30
Variants
Localization
Pass a BCP 47 locale string to derive labels via Intl.DateTimeFormat, or supply periodLabels directly when your i18n library already has the strings. Omitting both keeps AM / PM and avoids any SSR mismatch.
Scroll to step
With scrollToStep, click a segment to focus it, then scroll to change its value. Disabled by default so scroll never fires on hover and doesn't interfere with page scrolling.
Sizes
Usage
import { TimeInput } from "@/components/ui/time-input"
// Controlled (24h)
const [time, setTime] = React.useState("14:05")
<TimeInput value={time} onChange={setTime} />
// 12-hour with AM/PM
<TimeInput format="12h" value={time} onChange={setTime} />
// With seconds
<TimeInput showSeconds value={time} onChange={setTime} />
// Custom placeholder
<TimeInput placeholder="--" />
// Fill minutes with 00 when only hours are entered
<TimeInput autoFillMinutesOnBlur />
// Round to the nearest 5 minutes on blur
<TimeInput roundMinutesToNearest={5} />
// Round down or up instead
<TimeInput roundMinutesToNearest={5} roundMinutesMode="floor" />
<TimeInput roundMinutesToNearest={5} roundMinutesMode="ceil" />
// Avoid rolling past 24:00 at the end of the day
<TimeInput
roundMinutesToNearest={5}
roundLastIntervalDown
/>
// Allow business-hour overflow such as 27:00
<TimeInput allowOverflowHours maxOverflowHours={27} defaultValue="27:00" />
// Allow exactly 24:00, but nothing beyond
<TimeInput allowOverflowHours maxOverflowHours={24} defaultValue="24:00" />
// Localized AM/PM via Intl.DateTimeFormat
<TimeInput format="12h" locale="ko-KR" />
// Manual labels — use when your i18n library has the strings
<TimeInput format="12h" periodLabels={{ am: t("time.am"), pm: t("time.pm") }} />
// Scroll to step (click a segment first, then scroll)
<TimeInput scrollToStep />
// Unit suffixes instead of colons
<TimeInput unitSuffixes={{ hours: "時", minutes: "分", seconds: "秒" }} showSeconds />
<TimeInput unitSuffixes={{ hours: "h", minutes: "m", seconds: "s" }} showSeconds />
// Sizes
<TimeInput size="sm" />
<TimeInput size="default" />
<TimeInput size="lg" />
// Disabled
<TimeInput disabled defaultValue="09:00" />
// Native form submission
<form action="/submit">
<TimeInput name="departure" defaultValue="09:00" />
</form>
// React Hook Form
<Controller
control={control}
name="startTime"
render={({ field }) => (
<TimeInput value={field.value} onChange={field.onChange} />
)}
/>Keyboard
| ↑ / ↓ | Increment or decrement the focused segment |
| Tab | Move to the next segment |
| Shift+Tab | Move to the previous segment |
| Backspace on empty | Jump focus back to previous segment |
| Space / ↑ / ↓ on AM/PM | Toggle between AM and PM |
Props
| Prop | Type | Default |
|---|---|---|
| value | string | — |
| defaultValue | string | — |
| onChange | (value: string) => void | — |
| onBlur | (value: string) => void | — |
| format | "12h" | "24h" | "24h" |
| showSeconds | boolean | false |
| placeholder | string | — |
| autoFillMinutesOnBlur | boolean | false |
| roundMinutesToNearest | number | — |
| roundMinutesMode | "floor" | "ceil" | "nearest" | "nearest" |
| roundLastIntervalDown | boolean | false |
| allowOverflowHours | boolean | false |
| maxOverflowHours | number | 99 |
| locale | string | — |
| periodLabels | { am: string; pm: string } | — |
| scrollToStep | boolean | false |
| disabled | boolean | false |
| size | "sm" | "default" | "lg" | "default" |
| name | string | — |
| minutesAriaLabel | string | — |
| unitSuffixes | { hours: string; minutes: string; seconds?: string } | — |
| className | string | — |
The value / onChange pair always uses 24-hour format ("HH:mm" or "HH:mm:ss") regardless of the format prop. The component emits an empty string while either hours or minutes is unfilled.
Each segment input sets autoComplete="off" to suppress the browser autofill dropdown, which is not meaningful for time fields.