Calendar
A real `role="grid"` month rather than 42 tab stops — one tab stop, arrows for days, PageUp/PageDown for months, and six rows always, so paging never changes the height of what sits below it.
Three segments that behave like one field — typing past one advances, Backspace on an empty one goes back, arrows carry 59 minutes into the next hour, and pasting `1:30:00` fills all three.
components/ui/duration-input.tsxnpx hoverlab add duration-input
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
One file. Create components/ui/duration-input.tsx and paste.
'use client'
/**
* <DurationInput> — three boxes that behave like one field.
*
* A duration typed into a single text box has to be parsed, and every parser
* argues with its users about whether `1:30` is an hour and a half or ninety
* seconds. Three labelled segments never have that argument. The cost is
* that three inputs are three tab stops and feel like a form rather than a
* field, so most of this file is spent making them feel like one:
*
* - **Typing past a segment advances to the next.** Two digits in the
* hours box moves to minutes, the way a card-number field does.
* - **Backspace in an empty segment goes back** and puts the caret at the
* end of the previous one, so a wrong entry is fixed by holding
* Backspace rather than by aiming with the mouse.
* - **Up/Down step the segment under the caret** and carry properly: 59
* minutes + 1 is one more hour and zero minutes, not `60`.
* - **Pasting `1:30:00` fills all three**, because that is what people
* have on their clipboard. The paste handler is on the group, so it
* works whichever segment is focused.
*
* `role="group"` with an `aria-labelledby` is what ties the three together
* for a screen reader, and each segment keeps its own `aria-label` so the
* announcement is "Duration, Minutes, 30" rather than three loose numbers.
*
* The value is a plain number of seconds. Everything else — clamping, the
* carry, the zero padding — happens on the way in and out, so a consumer
* never has to hold three pieces of state to hold one duration.
*/
import * as React from 'react'
export interface DurationInputProps {
/** Total seconds. */
value?: number
onChange?: (seconds: number) => void
/** Drop the seconds box for durations nobody measures that finely. */
showSeconds?: boolean
/** Upper bound in seconds. Entry is clamped, not rejected. */
max?: number
disabled?: boolean
/** Names the group — "Session length", "Time spent". */
label: string
className?: string
}
interface Segment {
key: 'hours' | 'minutes' | 'seconds'
label: string
/** Exclusive ceiling for the segment, and how many of them make the next. */
size: number
width: string
}
const ALL_SEGMENTS: Segment[] = [
{ key: 'hours', label: 'Hours', size: Infinity, width: 'w-12' },
{ key: 'minutes', label: 'Minutes', size: 60, width: 'w-10' },
{ key: 'seconds', label: 'Seconds', size: 60, width: 'w-10' },
]
function split(total: number, showSeconds: boolean) {
const safe = Math.max(0, Math.floor(total))
if (!showSeconds) {
return { hours: Math.floor(safe / 3600), minutes: Math.floor((safe % 3600) / 60), seconds: 0 }
}
return {
hours: Math.floor(safe / 3600),
minutes: Math.floor((safe % 3600) / 60),
seconds: safe % 60,
}
}
export function DurationInput({
value = 0,
onChange,
showSeconds = true,
max,
disabled = false,
label,
className = '',
}: DurationInputProps) {
const id = React.useId()
const segments = React.useMemo(
() => (showSeconds ? ALL_SEGMENTS : ALL_SEGMENTS.slice(0, 2)),
[showSeconds],
)
const refs = React.useRef<(HTMLInputElement | null)[]>([])
const parts = split(value, showSeconds)
/* Which segment is mid-edit, and what is in it. A segment holding "" or
"0" while someone types the second digit of "05" cannot be derived from
the number, so it lives here until it commits. */
const [draft, setDraft] = React.useState<{ index: number; text: string } | null>(null)
const emit = (next: { hours: number; minutes: number; seconds: number }) => {
let total = next.hours * 3600 + next.minutes * 60 + next.seconds
if (max !== undefined) total = Math.min(total, max)
onChange?.(Math.max(0, total))
}
const commit = (index: number, raw: string) => {
const segment = segments[index]
const typed = Number(raw || '0')
/* Clamp the minute and second boxes rather than carrying: someone typing
"75" into minutes meant 75 minutes, but the box cannot show it, and
silently becoming 1:15 while they are still typing is worse than
stopping at 59. The Up/Down handler carries; typing does not. */
const bounded = Number.isFinite(segment.size)
? Math.min(typed, segment.size - 1)
: typed
emit({ ...parts, [segment.key]: bounded })
}
const step = (index: number, delta: number) => {
const segment = segments[index]
const unit = segment.key === 'hours' ? 3600 : segment.key === 'minutes' ? 60 : 1
emit(split(Math.max(0, value + delta * unit), showSeconds))
}
const focusSegment = (index: number, caretAtEnd = false) => {
const input = refs.current[index]
if (!input) return
input.focus()
if (caretAtEnd) {
const end = input.value.length
requestAnimationFrame(() => input.setSelectionRange(end, end))
} else {
input.select()
}
}
const onSegmentKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === 'ArrowUp') {
e.preventDefault()
step(index, 1)
} else if (e.key === 'ArrowDown') {
e.preventDefault()
step(index, -1)
} else if (e.key === 'ArrowLeft' && e.currentTarget.selectionStart === 0 && index > 0) {
e.preventDefault()
focusSegment(index - 1, true)
} else if (
e.key === 'ArrowRight' &&
e.currentTarget.selectionStart === e.currentTarget.value.length &&
index < segments.length - 1
) {
e.preventDefault()
focusSegment(index + 1)
} else if (e.key === 'Backspace' && e.currentTarget.value === '' && index > 0) {
e.preventDefault()
focusSegment(index - 1, true)
} else if (e.key === ':' || e.key === '.') {
// The separator people type between segments is a request to move on.
e.preventDefault()
if (index < segments.length - 1) focusSegment(index + 1)
}
}
const onPaste = (e: React.ClipboardEvent) => {
const text = e.clipboardData.getData('text').trim()
if (!/^\d{1,3}(:\d{1,2}){1,2}$/.test(text)) return
e.preventDefault()
const chunks = text.split(':').map(Number)
/* `1:30` is minutes:seconds when seconds are shown and hours:minutes
when they are not — the shorter form means the units on screen. */
const [hours, minutes, seconds] =
chunks.length === 3
? chunks
: showSeconds
? [0, chunks[0], chunks[1]]
: [chunks[0], chunks[1], 0]
setDraft(null)
emit({ hours, minutes, seconds })
}
return (
<div className={className}>
<span id={`${id}-label`} className="sr-only">
{label}
</span>
<div
role="group"
aria-labelledby={`${id}-label`}
onPaste={onPaste}
className={[
'inline-flex items-center gap-0.5 rounded-lg border border-border bg-background px-2 py-1',
'focus-within:ring-2 focus-within:ring-ring',
disabled ? 'opacity-60' : '',
]
.filter(Boolean)
.join(' ')}
>
{segments.map((segment, index) => (
<React.Fragment key={segment.key}>
{index > 0 ? (
<span aria-hidden className="text-sm text-muted-foreground">
:
</span>
) : null}
<span className="flex flex-col items-center">
<input
ref={(el) => {
refs.current[index] = el
}}
type="text"
inputMode="numeric"
autoComplete="off"
disabled={disabled}
aria-label={segment.label}
value={
draft?.index === index
? draft.text
: String(parts[segment.key]).padStart(2, '0')
}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(-2)
setDraft({ index, text: digits })
commit(index, digits)
// Two digits fills the box — move on, as a card field does.
if (digits.length === 2 && index < segments.length - 1) {
setDraft(null)
focusSegment(index + 1)
}
}}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => setDraft(null)}
onKeyDown={(e) => onSegmentKeyDown(e, index)}
className={`${segment.width} border border-transparent bg-transparent text-center font-mono text-sm tabular-nums text-foreground outline-none`}
/>
<span aria-hidden className="text-[0.625rem] uppercase text-muted-foreground">
{segment.label.slice(0, 3)}
</span>
</span>
</React.Fragment>
))}
</div>
</div>
)
}
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
labelrequiredNames the group — "Session length", "Time spent". | string | — |
valueTotal seconds. | number | 0 |
onChange | (seconds: number) => void | — |
showSecondsDrop the seconds box for durations nobody measures that finely. | boolean | true |
maxUpper bound in seconds. Entry is clamped, not rejected. | number | — |
disabled | boolean | false |
className | string | '' |
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
A real `role="grid"` month rather than 42 tab stops — one tab stop, arrows for days, PageUp/PageDown for months, and six rows always, so paging never changes the height of what sits below it.
The typing half most pickers drop, attached to the calendar: parses on blur, refuses to guess whether 3/4 is March or April, and leaves what it cannot read in the box instead of clearing it.
Slots at a fixed step with the taken ones struck through and the time zone rendered beside the value — the three things a booking needs and a native time input cannot say.