HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.2. Its API may change.

Time input

A time field on Vercel's UI SDK typed one part at a time, on a 12 or 24-hour clock with optional seconds, min and max, and a 24-hour value for forms.

Depends on
  • @ui-sdk/core
  • @ui-sdk/react
import { IconClock } from "@tabler/icons-react"

import { Label } from "@/components/ui/label"
import { TimeInput, TimeInputSegments } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputDemo() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="meeting-time">Meeting time</Label>
      <TimeInput aria-labelledby="meeting-time" defaultValue="09:30">
        <IconClock aria-hidden="true" />
        <TimeInputSegments />
      </TimeInput>
    </div>
  )
}

export { UiSdkTimeInputDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-time-input.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

UI SDK adds a little CSS of its own in a cascade layer named ui-sdk. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

@layer ui-sdk;

@import "tailwindcss";
import { TimeInput } from "@/components/ui/ui-sdk-time-input"
<Label id="meeting-time">Meeting time</Label>
<TimeInput aria-labelledby="meeting-time" defaultValue="09:30" />

The value is a 24-hour time like "14:30", the same as a native time input’s, whatever clock the field shows. It stays null until every part holds something. Each part is its own small input: type digits and it moves on, or press A or P for the half of the day.

The field is a group, so name it with aria-labelledby or aria-label. Clicking anywhere in the box focuses the nearest part.

TimeInput
└── TimeInputSegments
    └── TimeInputSegment

TimeInput renders its parts on its own. Add TimeInputSegments yourself to put an icon or other content beside them.

The field shows the locale’s clock. hourCycle picks 12 or 24 hours, and granularity adds seconds or shows the hour alone. All of them submit 24-hour values.

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputClock() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label id="clock-12">12-hour clock</Label>
        <TimeInput
          aria-labelledby="clock-12"
          hourCycle={12}
          defaultValue="18:45"
          locale="en-US"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-24">24-hour clock</Label>
        <TimeInput
          aria-labelledby="clock-24"
          hourCycle={24}
          defaultValue="18:45"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-seconds">With seconds</Label>
        <TimeInput
          aria-labelledby="clock-seconds"
          granularity="second"
          hourCycle={24}
          defaultValue="18:45:30"
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label id="clock-hour">Hour only</Label>
        <TimeInput
          aria-labelledby="clock-hour"
          granularity="hour"
          hourCycle={24}
          defaultValue="18:00"
        />
      </div>
    </div>
  )
}

export { UiSdkTimeInputClock }

A time before min or after max marks the field invalid, and its form won’t submit.

"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

const opens = "09:00"
const closes = "17:30"

function UiSdkTimeInputMinMax() {
  const [time, setTime] = React.useState<string | null>("16:00")
  const outside = time !== null && (time < opens || time > closes)

  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="pickup-time">Pickup</Label>
      <TimeInput
        aria-labelledby="pickup-time"
        aria-describedby="pickup-time-message"
        defaultValue="16:00"
        min={opens}
        max={closes}
        hourCycle={24}
        onValueChange={(value) => setTime(value)}
      />
      <p
        id="pickup-time-message"
        className={
          outside
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {outside ? "We’re closed then." : "Open 09:00 to 17:30."}
      </p>
    </div>
  )
}

export { UiSdkTimeInputMinMax }

invalid marks the field for checks of your own, like an end time before the start.

"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputRange() {
  const [start, setStart] = React.useState<string | null>("13:00")
  const [end, setEnd] = React.useState<string | null>("12:30")
  const invalid = start !== null && end !== null && end <= start

  return (
    <div className="flex w-full max-w-72 flex-col gap-2">
      <div className="grid grid-cols-2 gap-2">
        <div className="flex flex-col gap-2">
          <Label id="shift-start">Starts</Label>
          <TimeInput
            aria-labelledby="shift-start"
            defaultValue="13:00"
            onValueChange={(value) => setStart(value)}
          />
        </div>
        <div className="flex flex-col gap-2">
          <Label id="shift-end">Ends</Label>
          <TimeInput
            aria-labelledby="shift-end"
            aria-describedby="shift-message"
            defaultValue="12:30"
            invalid={invalid}
            onValueChange={(value) => setEnd(value)}
          />
        </div>
      </div>
      <p
        id="shift-message"
        className={
          invalid
            ? "m-0 text-xs text-destructive"
            : "m-0 text-xs text-muted-foreground"
        }
      >
        {invalid ? "The shift has to end after it starts." : "One shift a day."}
      </p>
    </div>
  )
}

export { UiSdkTimeInputRange }

locale sets the order, the separators and the names for the half of the day. Without it, the field follows the page’s language once it’s interactive.

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

const locales = [
  { locale: "en-US", label: "Time (en-US)" },
  { locale: "de-DE", label: "Uhrzeit (de-DE)" },
  { locale: "ja-JP", label: "時刻 (ja-JP)" },
  { locale: "ko-KR", label: "시간 (ko-KR)" },
]

function UiSdkTimeInputLocale() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-4">
      {locales.map(({ locale, label }) => (
        <div key={locale} lang={locale} className="flex flex-col gap-2">
          <Label id={`time-${locale}`}>{label}</Label>
          <TimeInput
            aria-labelledby={`time-${locale}`}
            locale={locale}
            defaultValue="18:45"
          />
        </div>
      ))}
    </div>
  )
}

export { UiSdkTimeInputLocale }

A hidden input submits the time under name. required stops an empty or half-typed time, and a reset brings back the default.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-40 flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSubmitted(String(data.get("reminder")))
      }}
    >
      <div className="flex flex-col gap-2">
        <Label id="reminder-time">Remind me at</Label>
        <TimeInput
          aria-labelledby="reminder-time"
          name="reminder"
          defaultValue="08:15"
          required
        />
      </div>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Save
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-sm text-muted-foreground tabular-nums">
        {submitted ? `Submitted ${submitted}` : "Nothing submitted yet."}
      </p>
    </form>
  )
}

export { UiSdkTimeInputForm }

createTimeInputController() sets, clears or focuses the field from anywhere. Focus a part by its unit, like "minute".

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  TimeInput,
  createTimeInputController,
} from "@/components/ui/ui-sdk-time-input"

function now() {
  const date = new Date()
  return `${String(date.getHours()).padStart(2, "0")}:${String(
    date.getMinutes()
  ).padStart(2, "0")}`
}

function UiSdkTimeInputController() {
  const [controller] = React.useState(createTimeInputController)
  const [value, setValue] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="log-time">Logged at</Label>
      <TimeInput
        aria-labelledby="log-time"
        controller={controller}
        onValueChange={(next) => setValue(next)}
      />
      <div className="flex items-center gap-2 pt-1">
        <Button
          size="sm"
          variant="outline"
          onClick={() => controller.setValue(now())}
        >
          Now
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => controller.focus("minute")}
        >
          Edit minutes
        </Button>
        <Button size="sm" variant="ghost" onClick={() => controller.clear()}>
          Clear
        </Button>
      </div>
      <p className="m-0 text-xs text-muted-foreground tabular-nums">
        {value ? `Value ${value}` : "Value null"}
      </p>
    </div>
  )
}

export { UiSdkTimeInputController }

A disabled field is skipped by focus and doesn’t submit.

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputDisabled() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="closed-time">Last entry</Label>
      <TimeInput aria-labelledby="closed-time" defaultValue="22:00" disabled />
    </div>
  )
}

export { UiSdkTimeInputDisabled }

A read-only field takes focus and submits, but keys and typing don’t change it.

import { Label } from "@/components/ui/label"
import { TimeInput } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputReadOnly() {
  return (
    <div className="flex w-full max-w-40 flex-col gap-2">
      <Label id="checkout-time">Checkout</Label>
      <TimeInput
        aria-labelledby="checkout-time"
        defaultValue="11:00"
        readOnly
      />
    </div>
  )
}

export { UiSdkTimeInputReadOnly }

The parts keep the locale’s order, and the arrow keys follow them on screen.

import { IconClock } from "@tabler/icons-react"

import { Label } from "@/components/ui/label"
import { TimeInput, TimeInputSegments } from "@/components/ui/ui-sdk-time-input"

function UiSdkTimeInputRtl() {
  return (
    <div dir="rtl" lang="ar" className="flex w-full max-w-40 flex-col gap-2">
      <Label id="rtl-time">وقت الاجتماع</Label>
      <TimeInput aria-labelledby="rtl-time" locale="ar-EG" defaultValue="18:45">
        <IconClock aria-hidden="true" />
        <TimeInputSegments />
      </TimeInput>
    </div>
  )
}

export { UiSdkTimeInputRtl }
KeyAction
0–9Fills the part, then moves to the next one.
↑↓Steps the part by one, wrapping around. An empty part starts at the current time.
PageUpPageDownSteps by more, like 15 minutes.
HomeEndGoes to the part’s first or last value.
←→Moves to the part before or after.
BackspaceRemoves the last digit, then empties the part, then moves back.
APChooses AM or PM on a 12-hour clock.
EnterSubmits the form with what’s typed.

The root is a group. Each part is a spinbutton named by its unit, like “hour”, with its value and range, and an empty part reads as “Empty”. The text between parts is hidden from screen readers.

Parts are real inputs, so phones show a number pad and typing works before the page’s script runs. They grow to 16px on touch screens so phones don’t zoom in, and the focused part is highlighted rather than showing a caret.

Each part wraps the UI SDK part of the same name and accepts all of its props, including render and a className that can be a function of the part’s state.

PropTypeDefault
value
string | null–
defaultValue
string | null–
onValueChangeRuns once every part holds something, and with null when one is emptied.
(value: string | null, details) => void–
hourCycleDefaults to the locale’s clock.
12 | 24–
granularity
"hour" | "minute" | "second""minute"
min
string–
max
string–
localeDefaults to the page’s language.
string–
invalid
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
name
string–
form
string–
controllerFrom createTimeInputController().
TimeInputController–
childrenDefaults to TimeInputSegments.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="time-input"Target the time input in CSS.
data-invalidPresent when the time is outside min and max, or invalid is set.
data-emptyPresent while every part is empty.
data-disabledPresent when disabled.
data-readonlyPresent when read-only.
data-requiredPresent when required.

Renders a TimeInputSegment for each part, in the locale’s order, with the text between them.

PropTypeDefault
segmentOne of the root state’s segments.
TimeInputSegmentData–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="time-input-segment"Target a part in CSS.
data-typehour, minute, second, dayPeriod, or literal for the text between parts.
data-emptyPresent while the part is empty.