Time Field
A segmented time input: hour, minute and AM/PM segments you type into or step with arrows; focus jumps forward.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/time-field.json'use client'
import { useId, useRef, useState } from 'react'
import './time-field.css'
export interface TimeFieldProps {
defaultValue?: string
onValueChange?: (v: string) => void
step?: number
hour12?: boolean
label?: string
fill?: 'dark' | 'light'
className?: string
}
type Seg = 'h' | 'm' | 'ap'
/** Segmented HH:MM time input. */
export function TimeField({ defaultValue = '09:30', onValueChange, step = 5, hour12 = true, label = 'Start time', fill = 'dark', className }: TimeFieldProps) {
const [h0, m0] = defaultValue.split(':').map(Number)
const [h, setH] = useState<number | null>(h0)
const [m, setM] = useState<number | null>(m0)
const buf = useRef('')
const refs = { h: useRef<HTMLSpanElement>(null), m: useRef<HTMLSpanElement>(null), ap: useRef<HTMLSpanElement>(null) }
const id = useId()
const order: Seg[] = hour12 ? ['h', 'm', 'ap'] : ['h', 'm']
const emit = (nh: number | null, nm: number | null) => { if (nh !== null && nm !== null) onValueChange?.(`${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`) }
const setBoth = (nh: number | null, nm: number | null) => { setH(nh); setM(nm); emit(nh, nm) }
const move = (s: Seg, d: number) => { const i = order.indexOf(s) + d; if (order[i]) refs[order[i]].current?.focus() }
const pm = h !== null && h >= 12
const dispH = h === null ? '--' : hour12 ? String(((h + 11) % 12) + 1).padStart(2, '0') : String(h).padStart(2, '0')
const key = (s: Seg) => (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight') { e.preventDefault(); return move(s, 1) }
if (e.key === 'ArrowLeft') { e.preventDefault(); return move(s, -1) }
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault(); const d = e.key === 'ArrowUp' ? 1 : -1
if (s === 'h') setBoth(((h ?? 0) + d + 24) % 24, m)
if (s === 'm') setBoth(h, (Math.round(((m ?? 0) + d * step) / step) * step + 60) % 60)
if (s === 'ap' && h !== null) setBoth((h + 12) % 24, m)
return
}
if (e.key === 'Backspace') { e.preventDefault(); buf.current = ''; if (s === 'h') setH(null); if (s === 'm') setM(null); return }
if (s === 'ap') { const k = e.key.toLowerCase(); if ((k === 'a' && pm) || (k === 'p' && !pm)) { e.preventDefault(); if (h !== null) setBoth((h + 12) % 24, m) } return }
if (!/^\d$/.test(e.key)) return
e.preventDefault()
buf.current += e.key
const n = Number(buf.current)
if (s === 'h') {
const max = hour12 ? 12 : 23
const to24 = (x: number) => (hour12 ? (x % 12) + (pm ? 12 : 0) : x)
if (buf.current.length === 2 || n * 10 > max) { const v = Math.min(max, Math.max(hour12 ? 1 : 0, n)); setBoth(to24(v), m); buf.current = ''; move('h', 1) } else setBoth(to24(n || (hour12 ? 12 : 0)), m)
} else {
if (buf.current.length === 2 || n > 5) { setBoth(h, Math.min(59, n)); buf.current = ''; if (hour12) move('m', 1) } else setBoth(h, n)
}
}
const segProps = (s: Seg, now: number | null, min: number, max: number, text: string) => ({
ref: refs[s], tabIndex: 0, role: 'spinbutton' as const, className: 'sd-tmf__seg', 'aria-label': s === 'h' ? 'Hours' : s === 'm' ? 'Minutes' : 'AM/PM',
'aria-valuemin': min, 'aria-valuemax': max, 'aria-valuenow': now ?? undefined, 'aria-valuetext': now === null ? 'Empty' : text,
'data-empty': now === null || undefined, onKeyDown: key(s), onFocus: () => { buf.current = '' },
})
const label12 = h === null || m === null ? 'Incomplete' : `${dispH}:${String(m).padStart(2, '0')}${hour12 ? (pm ? ' PM' : ' AM') : ''}`
return (
<div className={['sd-tmf', className].filter(Boolean).join(' ')} data-fill={fill}>
<span className="sd-tmf__lab" id={`${id}-l`}>{label}</span>
<div className="sd-tmf__box" role="group" aria-labelledby={`${id}-l`}>
<span {...segProps('h', h, 0, 23, dispH)}>{dispH}</span>
<span className="sd-tmf__colon" aria-hidden>:</span>
<span {...segProps('m', m, 0, 59, String(m))}>{m === null ? '--' : String(m).padStart(2, '0')}</span>
{hour12 && <span {...segProps('ap', pm ? 1 : 0, 0, 1, pm ? 'PM' : 'AM')} data-ap="">{pm ? 'PM' : 'AM'}</span>}
</div>
<p className="sd-tmf__out" aria-live="polite">{label12}</p>
</div>
)
}.sd-tmf { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tmf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tmf button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tmf :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tmf .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tmf { width: min(100%, 240px); }
.sd-tmf__lab { display: block; font-size: 12.5px; color: var(--mute); margin: 0 0 6px 2px; }
.sd-tmf__box { display: inline-flex; align-items: center; gap: 2px; height: 50px; padding: 0 10px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font: 600 22px/1 ui-sans-serif, system-ui; font-variant-numeric: tabular-nums; }
.sd-tmf__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-tmf__seg { min-width: 1.6em; padding: 4px 3px; border-radius: 7px; text-align: center; outline: 0; caret-color: transparent; cursor: default; transition: background-color 120ms; }
.sd-tmf__seg:focus { background: var(--strong); color: var(--on); }
.sd-tmf__seg[data-empty] { color: var(--mute); }
.sd-tmf__seg[data-ap] { min-width: 2.2em; font-size: 15px; margin-left: 6px; }
.sd-tmf__colon { color: var(--mute); }
.sd-tmf__out { margin: 8px 0 0 2px; font-size: 12px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-tmf *, .sd-tmf *::before, .sd-tmf *::after { animation: none !important; transition: none !important; } }Overview
Each segment is its own spinbutton. Typing “9” in hours waits for a second digit, “2” jumps straight to minutes; arrows step and wrap, and minutes can snap to 5 or 15.
A/P sets the meridiem. The whole field reads as one value to assistive tech and forms.
When to use
- Booking, reminders, schedules, alarms.
When not to use
- Picking from a few fixed slots: show the slots.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/time-field.json$
npx shadcn@latest add https://sundaeui.dev/r/time-field.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/time-field.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/time-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy time-field.tsx and time-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TimeField } from '@/components/sundae/time-field'
<TimeField />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValue24-hour “HH:MM”. | string | — |
onValueChangeCalled on change. | (v: string) => void | — |
stepMinute step for arrows. | number | 5 |
hour1212-hour display. | boolean | true |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A group labelled by the field label, with three spinbuttons that have aria-valuetext.
- Backspace clears a segment; Left/Right move between segments.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tmf; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Step
- ← / →
- Previous / next segment
- A / P
- AM / PM
FAQ
- Is Time Field free?
- Yes. Time Field is free for personal and commercial projects, with full source on this page.
- How do I install Time Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/time-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Time Field support keyboard and screen readers?
- A group labelled by the field label, with three spinbuttons that have aria-valuetext. Backspace clears a segment; Left/Right move between segments.
- Does Time Field respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Wheel Picker
Forms · Refined OTP Field
Forms · RefinedPassword Strength
Forms · RefinedPhone Input
Forms · RefinedUnit Input
Forms · RefinedBirthday Field
Forms · Refined