TimeInput
Overview
The TimeInput component is a form input that opens a popover spinner for time-of-day selection.
A clickable launcher icon on the right opens the popover, and you can type directly in the field with segmented keyboard entry. It supports 12-hour and 24-hour formats, optional seconds, custom hour/minute/second increments, min/max bounds, an unselectable-times predicate, and a native <input type="time"> fallback for touch devices.
Import
import { TimeInput } from '@allxsmith/bestax-bulma';
Usage
Basic TimeInput
<TimeInput label="Time" placeholder="HH:MM" />
Typing-first — the same example with openOnFocus={false}: focusing or clicking the field lets you type; open the popover with the launcher icon (or press ↓).
<TimeInput label="Time" placeholder="HH:MM" openOnFocus={false} />
Controlled
function example() { const [v, setV] = useState(() => { const d = new Date(); d.setHours(13, 45, 0, 0); return d; }); return ( <Block> <TimeInput label="Departure" value={v} onChange={setV} /> <Paragraph mt="2">Selected: {v ? v.toLocaleTimeString() : '—'}</Paragraph> </Block> ); }
Typing-first — the same controlled example with openOnFocus={false}: focus and type freely; the launcher icon on the right (or ↓) opens the popover.
function example() { const [v, setV] = useState(() => { const d = new Date(); d.setHours(13, 45, 0, 0); return d; }); return ( <Block> <TimeInput label="Departure" value={v} onChange={setV} openOnFocus={false} /> <Paragraph mt="2">Selected: {v ? v.toLocaleTimeString() : '—'}</Paragraph> </Block> ); }
12-hour Format
hourFormat="12" renders an AM/PM toggle column.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="12-hour" hourFormat="12" defaultValue={v} mobileNative={false} /> ); }
The OS-native pickers use the device's clock setting (12h/24h), so hourFormat is ignored there. This example forces mobileNative={false} so the 12-hour format shows on touch devices too.
Typing-first — identical, but with openOnFocus={false} so focusing lets you type (press a / p on the AM/PM segment); the launcher icon (or ↓) opens the popover.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="Press a / A / p / P on AM-PM" hourFormat="12" defaultValue={v} mobileNative={false} openOnFocus={false} /> ); }
24-hour Format
The default. Hours run 00–23.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="24-hour" hourFormat="24" defaultValue={v} mobileNative={false} /> ); }
Same as above — the OS-native pickers follow the device clock setting. This example forces mobileNative={false} to show 24-hour on touch devices.
Typing-first — the same 24-hour example with openOnFocus={false}: click in and type the time; the launcher icon (or ↓) brings up the popover.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="24-hour" hourFormat="24" defaultValue={v} mobileNative={false} openOnFocus={false} /> ); }
With Seconds
Adds a third spinner column.
function example() { const v = new Date(); v.setHours(10, 20, 30, 0); return ( <TimeInput label="With seconds" enableSeconds defaultValue={v} mobileNative={false} /> ); }
This example forces mobileNative={false} so the seconds wheel shows on every device.
Android Chrome renders a seconds spinner in its native time picker when step < 60 (which our component sets when enableSeconds is true). iOS Safari has no seconds wheel under any circumstances — Apple's native picker UI is hard-locked to hour/minute spinners regardless of step. The input value can carry seconds entered programmatically, but iOS users can't pick them in the wheel. If you need a seconds wheel on iOS, pass mobileNative={false} to force the custom wheel popover. See Mobile Native below for the full iOS-vs-Android picker support matrix.
Typing-first — the same example plus openOnFocus={false}: type across the hours / minutes / seconds segments, and open the popover with the launcher icon (or ↓).
function example() { const v = new Date(); v.setHours(10, 20, 30, 0); return ( <TimeInput label="Tab through hours / minutes / seconds" enableSeconds defaultValue={v} mobileNative={false} openOnFocus={false} /> ); }
Increment Steps
Pair incrementMinutes with min/max for slot-style pickers.
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="15-minute slots" incrementMinutes={15} defaultValue={v} mobileNative={false} /> ); }
iOS Safari shows every minute regardless of step. This example forces mobileNative={false} so the 15-minute stepping is enforced on every device.
Typing-first — the same example with openOnFocus={false}: increments only step the wheels, not typing — typed values are free-grained — so open the stepped wheels with the launcher icon (or ↓).
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="15-minute wheels — typing is free-grained" incrementMinutes={15} defaultValue={v} mobileNative={false} openOnFocus={false} /> ); }
Min and Max
Restrict to a time-of-day range.
function example() { const today = new Date(); const min = new Date(today); min.setHours(9, 0, 0, 0); const max = new Date(today); max.setHours(17, 0, 0, 0); const dv = new Date(today); dv.setHours(12, 0, 0, 0); return ( <TimeInput label="Office hours: 09:00 — 17:00" min={min} max={max} defaultValue={dv} /> ); }
min/max in the pickerOn iOS Safari the picker UI lets the user spin to any time; min/max only fire at form-submission validation (WebKit bug #225639, still open). Pass mobileNative={false} for iOS-side enforcement. Android Chrome's native picker does honor them.
Typing-first — the same bounds with openOnFocus={false}: typed entry is clamped to the window just like the wheels; open the popover with the launcher icon (or ↓).
function example() { const at = (h, m) => { const d = new Date(); d.setHours(h, m, 0, 0); return d; }; return ( <TimeInput label="Typed entry clamped to 09:00 – 17:00" min={at(9, 0)} max={at(17, 0)} defaultValue={at(12, 0)} openOnFocus={false} /> ); }
Unselectable Times
Skip times that match a predicate. Blocked values render dimmed in the wheel so the constraint is visible, the per-item button gets the disabled attribute (so clicks no-op), and keyboard / wheel scrolling automatically advances past them via the nextValid lookahead. The disabled state is dynamic — it's evaluated against the other columns' current values, so e.g. a predicate that blocks only 12:00–12:59 leaves all minutes enabled once the user moves the hour off 12. Manual typing also rejects blocked times, the same way min/max are enforced.
function example() { const v = new Date(); v.setHours(11, 30, 0, 0); return ( <TimeInput label="Lunch hour blocked" unselectableTimes={d => d.getHours() === 12} defaultValue={v} mobileNative={false} /> ); }
HTML has no predicate equivalent, so the OS-native pickers can't block any times. This example forces mobileNative={false} so the rule works on touch devices; in your app keep mobileNative="auto" and also validate in onChange.
Typing-first — the same predicate with openOnFocus={false}: typing or arrowing into a blocked time is rejected just like in the wheels, and the launcher icon (or ↓) opens the popover.
function example() { const v = new Date(); v.setHours(11, 30, 0, 0); return ( <TimeInput label="Lunch hour rejected while typing" unselectableTimes={d => d.getHours() === 12} defaultValue={v} mobileNative={false} openOnFocus={false} /> ); }
Manual Keyboard Entry
Focus the input — the hours segment highlights automatically and the keyboard alone can drive the full time entry without ever opening the popover. Segment mode activates whenever the format is a token string with padded tokens (HH, hh, mm, ss, A, a); custom Intl.DateTimeFormatOptions formats and unpadded tokens (H, h, m, s) fall back to free-form text entry.
These examples use openOnFocus={false} so the popover doesn't cover the input — set openOnFocus={true} (the default) and both UIs coexist. To turn segment typing off entirely, pass editable={false} (picker-only); to drop the popover and keep only the field, pass popover={false} (input-only).
With openOnFocus={false} (used here), clicking the field just lets you type — the popover does not appear on focus or click. Open the picker by clicking the launcher icon on the right (or pressing ↓). With the default openOnFocus={true}, focusing or clicking the field opens the popover immediately (you can still type while it's open).
Basic
Click into the field, then press ↑ / ↓ to change the hours, → to move to minutes, and continue.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="Click in, then use arrow keys" defaultValue={v} openOnFocus={false} /> ); }
Digit entry and auto-advance
Type digits to overwrite the active segment. The picker auto-advances when no further digit could form a valid value — for 24-hour hours, typing 5 advances immediately (no 50–59 to wait for), but typing 2 waits for an optional second digit (20–23 are still valid). Minutes/seconds advance after any digit 6–9, since 60+ is out of range.
function example() { const v = new Date(); v.setHours(9, 0, 0, 0); return ( <TimeInput label="Type digits — auto-advance after each segment" defaultValue={v} openOnFocus={false} /> ); }
Controlled with live value
The value updates on every increment, digit, or AM/PM toggle — exactly like the wheel popover. Bind to useState to see it.
function example() { const [v, setV] = useState(() => { const d = new Date(); d.setHours(13, 45, 0, 0); return d; }); return ( <Block> <TimeInput label="Arrow or type — value updates live" value={v} onChange={setV} openOnFocus={false} /> <Paragraph mt="2">Selected: {v ? v.toLocaleTimeString() : '—'}</Paragraph> </Block> ); }
Free-form fallback
When format is an Intl.DateTimeFormatOptions object (or uses unpadded tokens like H:m), segment mode silently disables: focusing the input does not highlight a segment, and arrow keys fall through to default behavior.
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="Free-form (Intl format)" format={{ hour: '2-digit', minute: '2-digit' }} defaultValue={v} openOnFocus={false} /> ); }
12-hour with seconds
The full segment walk: hh → mm → ss → AM/PM. → / ← move through all four segments, digits overwrite with auto-advance, and a / p toggle the meridiem.
function example() { const v = new Date(); v.setHours(13, 45, 30, 0); return ( <TimeInput label="hh:mm:ss + AM/PM" hourFormat="12" enableSeconds defaultValue={v} openOnFocus={false} /> ); }
Haptic-feel Feedback (audio tick + band pulse)
The custom-wheel popover fires three kinds of feedback per item crossing to make the picker feel tactile across platforms:
- Haptic (Android only) —
navigator.vibrate(5)fires unconditionally on Android Chrome / Firefox Android / Samsung Internet. iOS Safari does not exposenavigator.vibrate, and Apple patched the<input type="checkbox" switch>workaround in iOS 26.5 — there is no longer any web-only path to the Taptic Engine. - Audio thunk (opt-in via
audioTick) — a 160 Hz triangle-wave tone sweeping down to 110 Hz over ~30 ms. The low fundamental and downward pitch sweep are tuned to read as a body-felt thump rather than an ear-felt beep, matching the proprioceptive signature of native Taptic UI pops. - Visual band pulse (always-on) — the selection band briefly scales (
1 → 1.04 → 1) and brightens (× 1.15) over 110 ms on every tick. Single-element Web Animations API call withcomposite: 'replace'so rapid fling ticks restart cleanly. Respectsprefers-reduced-motion: reduce.
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="Audio thunk + band pulse" audioTick mobileNative={false} defaultValue={v} /> ); }
Typing-first — the same example with openOnFocus={false}: typing stays silent — the audio thunk and band pulse fire once you open the wheels via the launcher icon (or ↓) and scroll them.
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="Audio thunk + band pulse" audioTick mobileNative={false} defaultValue={v} openOnFocus={false} /> ); }
For a single switch that picks the right feedback per platform automatically — vibrate on Android, audio thunk on iOS — use haptics instead of audioTick:
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="Auto-routed haptics" haptics mobileNative={false} defaultValue={v} /> ); }
Typing-first — the same auto-routed feedback with openOnFocus={false}: the vibrate/thunk fires when you open the wheels with the launcher icon (or ↓) and scroll them, not while typing.
function example() { const v = new Date(); v.setHours(9, 30, 0, 0); return ( <TimeInput label="Auto-routed haptics" haptics mobileNative={false} defaultValue={v} openOnFocus={false} /> ); }
haptics feature-detects navigator.vibrate rather than UA-sniffing. On Android (vibrate present), no audio is layered on top of the real haptic; on iOS (vibrate absent), the audio thunk fills in.
audioTickis off by default; opt in per-instance.- Android still fires haptic via
navigator.vibrateindependently — enablingaudioTickadds the audible thunk on top. - The visual band pulse is always-on and gated only by
prefers-reduced-motion; no prop needed. - The first wheel touch unlocks the
AudioContext(Web Audio's gesture requirement); subsequent ticks play with no further user action. - The iPhone silent switch (and Android system volume) suppress the tick audio — matching native UX expectations.
- Native iOS wrappers (Capacitor, React Native + WebView) can bridge to
UIImpactFeedbackGeneratorfor real Taptic feedback; the audio thunk + visual pulse together are the closest pure-web equivalent.
Inline
Render the spinner directly without a popover.
function example() { const v = new Date(); v.setHours(8, 30, 0, 0); return <TimeInput label="Inline" inline defaultValue={v} />; }
Mobile Native
By default mobileNative='auto': on touch devices with a small viewport ((pointer: coarse) and (max-width: 768px)) the input swaps to a plain <input type="time"> so the OS-native time picker handles the interaction. On desktop the custom wheel popover renders. Pass true or false to override the auto-detection.
The OS-native fallback is just a <input type="time">, so it inherits each platform's behavior. The custom wheel popover (mobileNative={false}) honors every prop on every device.
Honored on Android Chrome but NOT on iOS Safari:
min/max— Android dims out-of-range times in the wheel; iOS lets the user spin to any value, only firing the constraint at form-submission validation. (WebKit bug #225639, still open as of 2026.)incrementMinutes,incrementHours,incrementSeconds— Android Chrome respectsstep(e.g. only 0/15/30/45 minutes selectable whenstep=900). iOS shows every value regardless.enableSeconds— Android Chrome shows a seconds spinner whenstep < 60. iOS has no seconds wheel under any circumstances.
Ignored on BOTH iOS Safari and Android Chrome (HTML-spec gaps):
unselectableTimes— HTML has no predicate equivalent; native pickers can't evaluate functions.hourFormat— both use the device's system clock setting (12h/24h).format,locale— both use the device's system locale; per-input overrides are ignored.placeholder— neither renders placeholder text on time inputs.
If any of these matter, pass mobileNative={false} to force the custom wheel popover (works on every device), or duplicate the constraint in onChange / server-side validation.
Force native
<TimeInput label="Always native" mobileNative={true} />
Force the custom wheel (even on mobile)
Useful when you want the same wheel UI everywhere — for example, if your app already provides a touch-friendly viewport-sized picker.
<TimeInput label="Always custom wheel" mobileNative={false} />
Formats
The format prop takes a token string or Intl.DateTimeFormatOptions. The same time rendered several ways:
function example() { const v = new Date(); v.setHours(13, 45, 30, 0); return ( <Block display="flex" flexDirection="column"> <TimeInput label="HH:mm (24h, default)" defaultValue={v} /> <TimeInput label="hh:mm A (12h)" format="hh:mm A" defaultValue={v} /> <TimeInput label="hh:mm a (lowercase)" format="hh:mm a" defaultValue={v} /> <TimeInput label="HH:mm:ss" format="HH:mm:ss" defaultValue={v} /> <TimeInput label="hh:mm:ss A" format="hh:mm:ss A" defaultValue={v} /> </Block> ); }
Typing-first — the same token formats with openOnFocus={false} on every instance: type into the segments each format defines; the launcher icon (or ↓) opens the popover.
function example() { const v = new Date(); v.setHours(13, 45, 30, 0); return ( <Block display="flex" flexDirection="column"> <TimeInput label="HH:mm (24h, default)" defaultValue={v} openOnFocus={false} /> <TimeInput label="hh:mm A (12h)" format="hh:mm A" defaultValue={v} openOnFocus={false} /> <TimeInput label="hh:mm a (lowercase)" format="hh:mm a" defaultValue={v} openOnFocus={false} /> <TimeInput label="HH:mm:ss" format="HH:mm:ss" defaultValue={v} openOnFocus={false} /> <TimeInput label="hh:mm:ss A" format="hh:mm:ss A" defaultValue={v} openOnFocus={false} /> </Block> ); }
Padded token formats support segmented typing; the meridiem (A/a) and seconds (ss) segments appear when the format includes them. Token formats render the same regardless of locale; pass an Intl.DateTimeFormatOptions object for locale-aware output (display-only unless you add a custom parse).
function example() { const v = new Date(); v.setHours(13, 45, 0, 0); return ( <TimeInput label="Intl, fr-FR (display only)" format={{ hour: '2-digit', minute: '2-digit' }} locale="fr-FR" editable={false} defaultValue={v} mobileNative={false} /> ); }
format and locale are ignored by the OS-native pickers (they use the device locale). This example forces mobileNative={false} so the locale-aware display shows on touch devices too.
Launcher Icon
A clickable launcher sits on the right and toggles the popover — handy for input-mode (openOnFocus={false}) where you type the value and click the icon to open the spinner. Override its glyph with triggerIconName, or hide it with triggerIcon={false} (the popover still opens on focus / click). The decorative left icon is independent: it shows by default, takes its glyph from iconLeftName, and is hidden with iconLeftName="".
<Block display="flex" flexDirection="column"> <TimeInput label="Default (left icon + right launcher)" /> <TimeInput label="Custom launcher glyph" triggerIconName="hourglass" /> <TimeInput label="No launcher" triggerIcon={false} /> <TimeInput label="Left icon hidden" iconLeftName="" /> </Block>
Typing-first — the same set with openOnFocus={false} on each instance: the field is type-first, which makes the right launcher icon (or ↓) the way into the popover.
<Block display="flex" flexDirection="column"> <TimeInput label="Default (left icon + right launcher)" openOnFocus={false} /> <TimeInput label="Custom launcher glyph" triggerIconName="hourglass" openOnFocus={false} /> <TimeInput label="No launcher (press ↓ to open)" triggerIcon={false} openOnFocus={false} /> <TimeInput label="Left icon hidden" iconLeftName="" openOnFocus={false} /> </Block>
The launcher gives way to a loading spinner at the same right edge, whether the TimeInput's own isLoading draws it or a Control it sits in does. Inside your own Control, set isLoading on that Control: the TimeInput renders no Control of its own there, so its isLoading draws nothing.
<Field label="Time"> <Control iconLeftName="clock" isLoading> <TimeInput placeholder="HH:MM" /> </Control> </Field>
Sizes
<Block display="flex" flexDirection="column"> <TimeInput label="Small" controlSize="small" size="small" /> <TimeInput label="Default" /> <TimeInput label="Medium" controlSize="medium" size="medium" /> <TimeInput label="Large" controlSize="large" size="large" /> </Block>
Typing-first — the same sizes with openOnFocus={false} on every instance: focusing lets you type, and the launcher icon (or ↓) opens the popover.
<Block display="flex" flexDirection="column"> <TimeInput label="Small" controlSize="small" size="small" openOnFocus={false} /> <TimeInput label="Default" openOnFocus={false} /> <TimeInput label="Medium" controlSize="medium" size="medium" openOnFocus={false} /> <TimeInput label="Large" controlSize="large" size="large" openOnFocus={false} /> </Block>
Colors
<Block display="flex" flexDirection="column"> <TimeInput label="Primary" color="primary" /> <TimeInput label="Info" color="info" /> <TimeInput label="Success" color="success" /> <TimeInput label="Warning" color="warning" /> <TimeInput label="Danger" color="danger" /> </Block>
Typing-first — the same colors with openOnFocus={false} everywhere: click in to type, and use the launcher icon (or ↓) to open the popover.
<Block display="flex" flexDirection="column"> <TimeInput label="Primary" color="primary" openOnFocus={false} /> <TimeInput label="Info" color="info" openOnFocus={false} /> <TimeInput label="Success" color="success" openOnFocus={false} /> <TimeInput label="Warning" color="warning" openOnFocus={false} /> <TimeInput label="Danger" color="danger" openOnFocus={false} /> </Block>
Inline: the wheels carry the color too, and rendered inline they show it without opening a popover.
function example() { const v = new Date(); v.setHours(8, 30, 0, 0); return ( <Block display="flex" flexWrap="wrap"> {['primary', 'link', 'info', 'success', 'warning', 'danger'].map(c => ( <Block key={c} mr="4"> <TimeInput label={c} color={c} inline defaultValue={v} /> </Block> ))} </Block> ); }
States
<Block display="flex" flexDirection="column"> <TimeInput label="Disabled" disabled /> <TimeInput label="Read only" readOnly defaultValue={new Date()} /> <TimeInput label="Loading" isLoading /> </Block>
Context-Aware Rendering
The TimeInput component is context-aware: it detects whether it is already inside a Field and adjusts its rendering accordingly.
Default (with label)
<TimeInput label="Time" placeholder="HH:MM" />
With Field Wrapper
function example() { return ( <Field horizontal label="Time"> <Field.Body> <Field> <TimeInput placeholder="HH:MM" /> </Field> </Field.Body> </Field> ); }
With Field and Control Wrappers
function example() { return ( <Field horizontal label="Time"> <Field.Body> <Field> <Control iconLeftName="clock"> <TimeInput placeholder="HH:MM" /> </Control> </Field> </Field.Body> </Field> ); }
Inside a Control with no Field around it, TimeInput renders no Field of its own either, unless you give it label, message, horizontal or fieldClassName. Those need a Field, so with any of them it keeps its own Field inside the Control and warns in development. Wrap the Control in a Field, as above, and set the label, horizontal and class name on that Field instead.
Keyboard Navigation
On the input (segmented entry)
Focus the input — the hours segment is automatically highlighted. Segment mode activates whenever the format is a token string with padded tokens (HH, hh, mm, ss, A, a); Intl-options formats and variable-width tokens (H, h, m, s) fall back to free-form text entry.
| Key | Action |
|---|---|
↑ / ↓ | Increment / decrement the active segment (wraps at boundaries) |
← / → | Move to previous / next segment (hour ↔ minute ↔ second ↔ AM/PM) |
0–9 | Overwrite the active segment; auto-advances when no further digit is valid |
a / A / p / P | Toggle AM/PM on the meridiem segment |
Backspace | Clear the typed-digit buffer; if already cleared, move to the previous segment |
Tab | Clear segment selection so focus moves out naturally |
Escape | Close popover |
Enter | Close popover when closeOnSelect={true} (value is already committed live) |
On a wheel column (when the popover is open)
| Key | Action |
|---|---|
↑ / ↓ | Increment / decrement focused column |
PageUp / PageDown | Increment / decrement by 5× |
Tab | Move focus to next column |
Enter | Commit live value, close popover |
Form Submission
TimeInput participates in HTML form submission. Pass a name and the value is forwarded as HH:MM (or HH:MM:SS if enableSeconds).
| Prop | Description |
|---|---|
name | Form field name. |
form | Optional id of the form the input belongs to. |
required | Marks the field as required for native HTML form validation. |
function TimeInputFormDemo() { const [submitted, setSubmitted] = React.useState(''); return ( <form onSubmit={e => { e.preventDefault(); const fd = new FormData(e.currentTarget); setSubmitted(JSON.stringify(Array.from(fd.entries()), null, 2)); }} > <TimeInput name="appointment" label="Appointment time" required /> <div style={{ marginTop: '1rem' }}> <button type="submit" className="button is-primary"> Submit </button> </div> {submitted && <pre style={{ marginTop: '1rem' }}>{submitted}</pre>} </form> ); }
Accessibility
- Trigger uses
role="combobox"witharia-haspopup="dialog",aria-expanded, andaria-controls. - Popover panel has
role="dialog"with an accessible name. - Each spinner column has
role="spinbutton"witharia-valuemin,aria-valuemax,aria-valuenow, andaria-valuetext. - AM/PM toggle exposes
aria-pressed. - Honors
prefers-reduced-motion.
Related Components
- DateInput - Date-only picker with a calendar popover.
- DateTimeInput - Combined date + time picker.
- Input - For basic text input.
Additional Resources
Combine incrementMinutes={5} (or 15/30) with min and max to build a tight slot picker — the spinner will only land on valid slots.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | React.ReactNode | — | Field label. Automatically associated with the input via htmlFor — uses your id when provided, otherwise a generated one. Not wired in inline mode (no visible input to label) and dropped inside an outer Field. |
labelSize | 'small' | 'normal' | 'medium' | 'large' | — | Size for the label. |
labelProps | React.LabelHTMLAttributes<HTMLLabelElement> & { [key: string]: unknown; } | — | Props for the label element. An explicit htmlFor here overrides the automatic association (no id is generated then). |
horizontal | boolean | false | Render the field with horizontal layout. |
iconLeft | IconProps | React.ReactNode | — | Icon props for the left icon. Bulma gives control icons pointer-events: none, so a clickable node here never receives a click. Put a button beside the input in its own addon Control instead. |
iconRight | IconProps | React.ReactNode | — | Icon props for the right icon. Bulma gives control icons pointer-events: none, so a clickable node here never receives a click. Put a button beside the input in its own addon Control instead. |
iconRightName | string | — | Shortcut for the right icon name. |
iconLeftSize | 'small' | 'medium' | 'large' | — | Shortcut for left icon size. |
iconRightSize | 'small' | 'medium' | 'large' | — | Shortcut for right icon size. |
hasIconsLeft | boolean | false | Force the left icon container. |
hasIconsRight | boolean | false | Force the right icon container. |
isLoading | boolean | false | Shows a loading spinner on the Control it renders, and hides the launcher (triggerIcon) while it does. Inside your own Control it renders none, so this draws nothing; set isLoading on that Control. Under prefers-reduced-motion: reduce the spinner stops and stays drawn (with bestax's CSS loaded). |
triggerIcon | boolean | true | Show a clickable launcher button on the right that toggles the popover. Hidden by default while a spinner shows at the same right edge: this component's isLoading when it renders its own Control, or the enclosing Control's isLoading inside one. |
isExpanded | boolean | false | Expand the control to fill its container. |
controlSize | 'small' | 'medium' | 'large' | — | Size of the wrapping Control. |
message | React.ReactNode | — | Help/validation text below the input. |
messageColor | 'primary' | 'link' | 'info' | 'success' | 'warning' | 'danger' | — | Color modifier for the help message. |
fieldClassName | string | — | Additional CSS classes for the Field wrapper. |
controlClassName | string | — | Additional CSS classes for the Control wrapper. |
value | Date | null | — | Controlled selected time (date portion is preserved). |
defaultValue | Date | null | — | Initial value for uncontrolled usage. |
onChange | (d: Date | null) => void | — | Fired when the value changes. |
onOpen | () => void | — | Fired when the popover opens. |
onClose | () => void | — | Fired when the popover closes. |
min | Date | — | Earliest selectable time. A min before year 1 is raised to midnight on 1 January of year 1, and a value dated before then is out of range, so the wheels and typing change nothing on it, as in the date pickers. |
max | Date | — | Latest selectable time. |
disabled | boolean | false | Disable the input. |
readOnly | boolean | false | Make the input read-only. |
placeholder | string | — | Placeholder text for the input. |
format | Intl.DateTimeFormatOptions | string | (see below) | Token format string or Intl.DateTimeFormat options. |
parse | (s: string) => Date | null | — | Custom parser. Enter and leaving the field call it only if the user changed the text, so focus passing through commits nothing and the value keeps what the format leaves out, such as seconds and the date. |
locale | string | — | BCP-47 locale tag for Intl formatting. |
inline | boolean | false | Render the spinner inline (no popover). |
mobileNative | boolean | 'auto' | 'auto' | Use <input type="time"> on coarse-pointer + small-viewport devices. |
editable | boolean | true | Allow segmented keyboard typing in the input (type the time directly, auto-advancing across segments). false makes the field picker-only. |
popover | boolean | true | Whether the spinner popover exists. false makes the field input-only (segmented typing, no popover). |
openOnFocus | boolean | true | Open the popover when the input is focused. Focus that a closing popover hands back to the input leaves it closed. Dismissing it commits nothing: an empty field stays empty, and leaving afterwards commits only what was typed since. |
closeOnSelect | boolean | false | Close the popover after a time is selected (off by default). |
position | 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'auto' | 'bottom-left' | Popover anchor position relative to the input. |
appendToBody | boolean | false | Render the popover into document.body via portal. On a narrow screen an in-place popover spans the bottom of the viewport, while a portaled one stays on its position corner. |
color | 'primary' | 'link' | 'info' | 'success' | 'warning' | 'danger' | — | Bulma color modifier for the input, also carried by the wheels, where it colors the selection band. A focused wheel's keyboard focus ring is drawn inside the band in the color's -invert, like the selected value, so it shows on the fill. Unset, the wheels use --bulma-timeinput-wheel-selected-bg, which defaults to primary. |
size | 'small' | 'medium' | 'large' | — | Size variant. |
isRounded | boolean | false | Render the input with rounded corners. |
hourFormat | '12' | '24' | '24' | Hour format. '12' shows an AM/PM toggle. |
enableSeconds | boolean | false | Show a seconds column. Note: iOS Safari's native time picker UI does not include a seconds wheel; pass mobileNative={false} if you need one on iOS. |
incrementHours | number | 1 | Hour step for the spinner. |
incrementMinutes | number | 1 | Minute step. Combine with min/max for slot-style pickers. |
incrementSeconds | number | 1 | Second step. Default 1. |
unselectableTimes | (d: Date) => boolean | — | Predicate returning true for times that should be skipped. Blocked times are also rejected during manual typing. |
iconLeftName | string | 'clock' | Decorative left icon glyph for the wrapping Control (shown by default). Set '' to hide. |
triggerIconName | string | 'chevron-down' | Glyph for the right launcher button. |
labels | PickerLabels | — | Optional translatable string overrides. |
audioTick | boolean | false | Play a short audible click on each wheel-item crossing. Substitute for haptic feedback on iOS Safari (which has no web haptic API as of May 2026); on Android, navigator.vibrate(5) fires automatically regardless. |
haptics | boolean | false | Auto-route platform-appropriate feedback: vibrate on Android (already happening), audio thunk on iOS (where vibrate is unavailable). One switch instead of platform-sniffing on the consumer side. audioTick={true} always wins. |
name | string | — | Form field name. |
form | string | — | Form id the input belongs to. |
required | boolean | false | Marks the input as required. |
className | string | — | Additional CSS classes for the input. |
ref | React.Ref<HTMLInputElement> | — | Forwarded to the underlying <input>. |
... | All standard <input> attributes and Bulma helper props | — | See Helper Props |
The default format depends on hourFormat and enableSeconds: 'HH:mm', 'HH:mm:ss', 'hh:mm A', or 'hh:mm:ss A'.
When you pass an explicit token format, that format is the source of truth for the wheel spinner: a 12-hour format (h/hh with A/a) shows a 12-hour wheel with an AM/PM column, and a 24-hour format (H/HH) shows a 24-hour wheel — regardless of hourFormat. So hourFormat only matters when you don't pass a format. (If format is an Intl.DateTimeFormat options object rather than a token string, the cycle can't be read from it and the wheel falls back to hourFormat.)
CSS & Sass Variables
TimeInput registers these variables on its own .input element. Override them there (or via className) — a value set on an ancestor is only inherited, and loses to the component-level declaration. See Theme.
| CSS Variable | Sass Variable | Default |
|---|---|---|
--bulma-picker-popover-z-index ‡ | $picker-popover-z-index | 30 |
--bulma-picker-popover-background ‡ | $picker-popover-background | var(--bulma-scheme-main) |
--bulma-picker-popover-radius ‡ | $picker-popover-radius | var(--bulma-radius-large) |
--bulma-picker-popover-shadow ‡ | $picker-popover-shadow | 0 8px 24px hsla(0, 0%, 0%, 0.18) |
--bulma-picker-popover-border-color ‡ | $picker-popover-border-color | var(--bulma-border) |
--bulma-picker-popover-padding ‡ | $picker-popover-padding | 0.75rem |
--bulma-picker-popover-offset ‡ | $picker-popover-offset | 4px |
--bulma-picker-popover-animation-duration ‡ | $picker-popover-animation-duration | 0.15s |
--bulma-timeinput-wheel-width ‡ | $timeinput-wheel-width | 3rem |
--bulma-timeinput-wheel-item-height ‡ | $timeinput-wheel-item-height | 2rem |
--bulma-timeinput-wheel-gap ‡ | $timeinput-wheel-gap | 0.4rem |
--bulma-timeinput-wheel-bg ‡ | $timeinput-wheel-bg | transparent |
--bulma-timeinput-wheel-color ‡ | $timeinput-wheel-color | var(--bulma-text) |
--bulma-timeinput-wheel-dim-color ‡ | $timeinput-wheel-dim-color | var(--bulma-text-weak) |
--bulma-timeinput-wheel-hover-bg ‡ | $timeinput-wheel-hover-bg | hsla(0, 0%, 50%, 0.13) |
--bulma-timeinput-wheel-selected-bg ‡ | $timeinput-wheel-selected-bg | var(--bulma-primary) |
--bulma-timeinput-wheel-selected-color ‡ | $timeinput-wheel-selected-color | var(--bulma-primary-invert) |
--bulma-timeinput-wheel-radius ‡ | $timeinput-wheel-radius | var(--bulma-radius) |
--bulma-timeinput-wheel-mask ‡ | $timeinput-wheel-mask | linear-gradient(180deg, transparent 0, black 18%, black 82%, transparent 100%) |
--bulma-timeinput-separator-color ‡ | $timeinput-separator-color | var(--bulma-text-weak) |
--bulma-timeinput-separator-size ‡ | $timeinput-separator-size | var(--bulma-size-5) |
--bulma-timeinput-footer-padding ‡ | $timeinput-footer-padding | 0.5rem 0 0 |
--bulma-input-h | $input-h | var(--bulma-scheme-h) |
--bulma-input-s | $input-s | var(--bulma-scheme-s) |
--bulma-input-l | $input-l | var(--bulma-scheme-main-l) |
--bulma-input-border-style | $input-border-style | solid |
--bulma-input-border-width | $input-border-width | var(--bulma-control-border-width) |
--bulma-input-border-l | $input-border-l | var(--bulma-border-l) |
--bulma-input-border-l-delta | $input-border-l-delta | 0% |
--bulma-input-border-color | $input-border-color | hsl(var(--bulma-input-h), var(--bulma-input-s), calc(var(--bulma-input-border-l) + var(--bulma-input-border-l-delta))) |
--bulma-input-hover-border-l-delta | $input-hover-border-l-delta | var(--bulma-hover-border-l-delta) |
--bulma-input-active-border-l-delta | $input-active-border-l-delta | var(--bulma-active-border-l-delta) |
--bulma-input-focus-h | $input-focus-h | var(--bulma-focus-h) |
--bulma-input-focus-s | $input-focus-s | var(--bulma-focus-s) |
--bulma-input-focus-l | $input-focus-l | var(--bulma-focus-l) |
--bulma-input-focus-shadow-size | $input-focus-shadow-size | var(--bulma-focus-shadow-size) |
--bulma-input-focus-shadow-alpha | $input-focus-shadow-alpha | var(--bulma-focus-shadow-alpha) |
--bulma-input-color-l | $input-color-l | var(--bulma-text-strong-l) |
--bulma-input-background-l | $input-background-l | var(--bulma-scheme-main-l) |
--bulma-input-background-l-delta | $input-background-l-delta | 0% |
--bulma-input-height | $input-height | var(--bulma-control-height) |
--bulma-input-shadow | $input-shadow | inset 0 0.0625em 0.125em hsla(var(--bulma-scheme-h), var(--bulma-scheme-s), var(--bulma-scheme-invert-l), 0.05) |
--bulma-input-placeholder-color | $input-placeholder-color | hsla(var(--bulma-text-h), var(--bulma-text-s), var(--bulma-text-strong-l), 0.3) |
--bulma-input-disabled-color | $input-disabled-color | var(--bulma-text-weak) |
--bulma-input-disabled-background-color | $input-disabled-background-color | var(--bulma-background) |
--bulma-input-disabled-border-color | $input-disabled-border-color | var(--bulma-background) |
--bulma-input-disabled-placeholder-color | $input-disabled-placeholder-color | hsla(var(--bulma-text-h), var(--bulma-text-s), var(--bulma-text-weak-l), 0.3) |
--bulma-input-arrow | $input-arrow | var(--bulma-link) |
--bulma-input-icon-color | $input-icon-color | var(--bulma-text-light) |
--bulma-input-icon-hover-color | $input-icon-hover-color | var(--bulma-text-weak) |
--bulma-input-icon-focus-color | $input-icon-focus-color | var(--bulma-link) |
--bulma-input-radius | $input-radius | var(--bulma-radius) |
‡ declared on a constituent element: values set via className, the style prop, or an ancestor are only inherited and lose — target the declaring element in your CSS.
Item height: --bulma-timeinput-wheel-item-height ($timeinput-wheel-item-height) is registered, and nothing reads it. The component sets each wheel item's height inline, taller on small viewports, because the wheels position their items from that height in script. It stays registered so a Sass build that configures it keeps compiling.