Time Picker
A specialized input component for selecting hours, minutes, and periods (AM/PM) with intuitive keyboard and mouse controls.
import { TimePicker } from "@/components/ui/timePicker/timePicker";npx @bloomui-react/cli add timePicker"use client";
import { Icon } from "@iconify/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TimePickerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
variant?:
| "default"
| "bordered"
| "flat"
| "underlined"
| "filled"
| "glassmorphism"
| "gradient-border"
| "glow";
value?: string;
onChange?: (time: string) => void;
format?: "12h" | "24h";
step?: number;
size?: "sm" | "md" | "lg";
label?: React.ReactNode;
description?: React.ReactNode;
isInvalid?: boolean;
isDisabled?: boolean;
useWheel?: boolean;
locale?: string;
timeZone?: string;
isRequired?: boolean;
}
export const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
(
{
variant = "default",
value = "12:00 PM",
onChange,
format = "12h",
step = 1,
size = "md",
label,
description,
isInvalid,
isDisabled,
useWheel = false,
locale = "en-US",
timeZone,
isRequired = false,
className,
...props
},
ref,
) => {
const parseTime = (timeStr: string) => {
const [time, period] = timeStr.split(" ");
const [hours, minutes] = (time || "12:00").split(":");
return {
hours: hours || (format === "12h" ? "12" : "00"),
minutes: minutes || "00",
period: (period || "PM") as "AM" | "PM",
};
};
const [timeState, setTimeState] = React.useState(parseTime(value));
React.useEffect(() => {
if (value) {
setTimeState(parseTime(value));
}
}, [value, format]);
const updateTime = (updates: Partial<typeof timeState>) => {
const newState = { ...timeState, ...updates };
setTimeState(newState);
const newTimeStr =
format === "12h"
? `${newState.hours}:${newState.minutes} ${newState.period}`
: `${newState.hours}:${newState.minutes}`;
onChange?.(newTimeStr);
};
const handleHourChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value.replace(/\D/g, "");
if (val.length > 2) val = val.slice(0, 2);
const numVal = parseInt(val, 10);
if (!Number.isNaN(numVal)) {
if (format === "12h") {
if (numVal > 12) val = "12";
if (numVal === 0 && val.length === 2) val = "12";
} else {
if (numVal > 23) val = "23";
}
}
updateTime({ hours: val });
};
const handleMinuteChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value.replace(/\D/g, "");
if (val.length > 2) val = val.slice(0, 2);
const numVal = parseInt(val, 10);
if (!Number.isNaN(numVal)) {
if (numVal > 59) val = "59";
}
updateTime({ minutes: val });
};
const incrementHour = () => {
let h = parseInt(timeState.hours, 10) || 0;
if (format === "12h") {
h = h >= 12 ? 1 : h + 1;
} else {
h = h >= 23 ? 0 : h + 1;
}
updateTime({ hours: h.toString().padStart(2, "0") });
};
const decrementHour = () => {
let h = parseInt(timeState.hours, 10) || 0;
if (format === "12h") {
h = h <= 1 ? 12 : h - 1;
} else {
h = h <= 0 ? 23 : h - 1;
}
updateTime({ hours: h.toString().padStart(2, "0") });
};
const incrementMinute = () => {
let m = parseInt(timeState.minutes, 10) || 0;
m = m + step;
if (m > 59) m = 0;
updateTime({ minutes: m.toString().padStart(2, "0") });
};
const decrementMinute = () => {
let m = parseInt(timeState.minutes, 10) || 0;
m = m - step;
if (m < 0) m = 60 - step;
updateTime({ minutes: m.toString().padStart(2, "0") });
};
const togglePeriod = () => {
updateTime({ period: timeState.period === "AM" ? "PM" : "AM" });
};
const sizeClasses = {
sm: "h-9 px-3",
md: "h-10 px-3",
lg: "h-12 px-4",
};
const inputSizeClasses = {
sm: "w-6 text-sm",
md: "w-7 text-base",
lg: "w-8 text-lg",
};
const variantClasses = {
default:
"bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
bordered:
"bg-transparent border-2 border-zinc-200 dark:border-zinc-800 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
flat: "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800 focus-within:bg-white dark:focus-within:bg-zinc-900 focus-within:border-sky-500 border text-zinc-900 dark:text-zinc-100",
underlined:
"bg-transparent border-b-2 border-zinc-200 dark:border-zinc-800 rounded-none px-0 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
filled:
"bg-zinc-100 dark:bg-zinc-800/80 border border-transparent focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
glassmorphism:
"backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 focus-within:border-sky-500 shadow-lg text-zinc-900 dark:text-zinc-100",
"gradient-border":
"bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 relative [background-clip:padding-box] border border-transparent before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:p-[1px] before:bg-gradient-to-r before:from-sky-500 before:via-indigo-500 before:to-pink-500 focus-within:ring-2 focus-within:ring-indigo-500/30",
glow: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:shadow-[0_0_12px_rgba(14,165,233,0.35)] text-zinc-900 dark:text-zinc-100",
};
const hoursList = React.useMemo(() => {
if (format === "12h") {
return Array.from({ length: 12 }, (_, i) =>
(i + 1).toString().padStart(2, "0"),
);
}
return Array.from({ length: 24 }, (_, i) =>
i.toString().padStart(2, "0"),
);
}, [format]);
const minutesList = React.useMemo(() => {
const list: string[] = [];
for (let i = 0; i < 60; i += step) {
list.push(i.toString().padStart(2, "0"));
}
return list;
}, [step]);
if (useWheel) {
return (
<div
className={cn("flex flex-col gap-2", className)}
ref={ref}
{...props}
>
{label && (
<label className="text-sm font-medium text-zinc-900 dark:text-zinc-100 select-none">
{label}
{isRequired && <span className="text-rose-500 ml-0.5">*</span>}
</label>
)}
<div
className={cn(
"flex items-center justify-center gap-2 p-3 w-fit select-none",
variant !== "underlined" ? "rounded-2xl" : "rounded-none",
variantClasses[variant],
isInvalid && "border-rose-500",
isDisabled && "opacity-50 pointer-events-none",
)}
>
<div className="flex flex-col items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">
Hours
</span>
<div className="h-32 w-12 overflow-y-auto snap-y snap-mandatory rounded-xl bg-zinc-50 dark:bg-zinc-800/60 p-1 border border-zinc-200/60 dark:border-zinc-700/60 scrollbar-none">
{hoursList.map((h) => (
<button
key={h}
type="button"
onClick={() => updateTime({ hours: h })}
className={cn(
"w-full py-1.5 text-xs font-mono font-bold rounded-lg transition-colors snap-center cursor-pointer",
timeState.hours === h
? "bg-sky-500 text-white shadow-xs"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60",
)}
>
{h}
</button>
))}
</div>
</div>
<span className="text-zinc-400 font-bold self-center pt-5 text-sm">
:
</span>
<div className="flex flex-col items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">
Minutes
</span>
<div className="h-32 w-12 overflow-y-auto snap-y snap-mandatory rounded-xl bg-zinc-50 dark:bg-zinc-800/60 p-1 border border-zinc-200/60 dark:border-zinc-700/60 scrollbar-none">
{minutesList.map((m) => (
<button
key={m}
type="button"
onClick={() => updateTime({ minutes: m })}
className={cn(
"w-full py-1.5 text-xs font-mono font-bold rounded-lg transition-colors snap-center cursor-pointer",
timeState.minutes === m
? "bg-sky-500 text-white shadow-xs"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60",
)}
>
{m}
</button>
))}
</div>
</div>
{format === "12h" && (
<div className="flex flex-col items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">
Period
</span>
<div className="h-32 w-12 flex flex-col justify-center gap-2 rounded-xl bg-zinc-50 dark:bg-zinc-800/60 p-1 border border-zinc-200/60 dark:border-zinc-700/60">
{["AM", "PM"].map((p) => (
<button
key={p}
type="button"
onClick={() => updateTime({ period: p as "AM" | "PM" })}
className={cn(
"w-full py-2 text-xs font-bold rounded-lg transition-colors cursor-pointer",
timeState.period === p
? "bg-sky-500 text-white shadow-xs"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60",
)}
>
{p}
</button>
))}
</div>
</div>
)}
</div>
{description && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
);
}
return (
<div
className={cn("flex flex-col gap-2", className)}
ref={ref}
{...props}
>
{label && (
<label className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{label}
{isRequired && <span className="text-rose-500 ml-0.5">*</span>}
</label>
)}
<div
className={cn(
"flex items-center gap-1 w-fit transition-colors",
variant !== "underlined" ? "rounded-2xl" : "rounded-none",
sizeClasses[size],
variantClasses[variant],
isInvalid &&
"border-rose-500 focus-within:ring-rose-500/20 focus-within:border-rose-500",
isDisabled && "opacity-50 pointer-events-none",
)}
>
<div className="flex flex-col items-center justify-center -space-y-1">
<button
type="button"
onClick={incrementHour}
className="text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 outline-none"
tabIndex={-1}
>
<Icon icon="hugeicons:arrow-up-01" className="w-3 h-3" />
</button>
<input
type="text"
value={timeState.hours}
onChange={handleHourChange}
onBlur={() => {
const val =
parseInt(timeState.hours, 10) || (format === "12h" ? 12 : 0);
updateTime({ hours: val.toString().padStart(2, "0") });
}}
className={cn(
"bg-transparent text-center font-medium text-zinc-900 dark:text-zinc-100 outline-none placeholder:text-zinc-400",
inputSizeClasses[size],
)}
placeholder="12"
/>
<button
type="button"
onClick={decrementHour}
className="text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 outline-none"
tabIndex={-1}
>
<Icon icon="hugeicons:arrow-down-01" className="w-3 h-3" />
</button>
</div>
<span className="text-zinc-400 font-medium pb-0.5">:</span>
<div className="flex flex-col items-center justify-center -space-y-1">
<button
type="button"
onClick={incrementMinute}
className="text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 outline-none"
tabIndex={-1}
>
<Icon icon="hugeicons:arrow-up-01" className="w-3 h-3" />
</button>
<input
type="text"
value={timeState.minutes}
onChange={handleMinuteChange}
onBlur={() => {
const val = parseInt(timeState.minutes, 10) || 0;
updateTime({ minutes: val.toString().padStart(2, "0") });
}}
className={cn(
"bg-transparent text-center font-medium text-zinc-900 dark:text-zinc-100 outline-none placeholder:text-zinc-400",
inputSizeClasses[size],
)}
placeholder="00"
/>
<button
type="button"
onClick={decrementMinute}
className="text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 outline-none"
tabIndex={-1}
>
<Icon icon="hugeicons:arrow-down-01" className="w-3 h-3" />
</button>
</div>
{format === "12h" && (
<button
type="button"
onClick={togglePeriod}
className={cn(
"ml-1 flex items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 font-medium transition-colors hover:bg-zinc-200 dark:hover:bg-zinc-700",
size === "sm" && "px-1.5 py-0.5 text-[10px]",
size === "md" && "px-2 py-1 text-xs",
size === "lg" && "px-2.5 py-1.5 text-sm",
)}
>
{timeState.period}
</button>
)}
</div>
{description && (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
);
},
);
TimePicker.displayName = "TimePicker";
Default
A standard 12-hour time picker with AM/PM toggle.
Select when the meeting begins.
Selected: 02:30 PM
Variants
Defines the visual appearance of the Time Picker trigger using the variant prop.
default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlinedSizes
Available in three sizes: small, medium, and large.
sm | md | lgRequired State
Displays an asterisk next to the label indicating that choosing a time is mandatory.
12h vs 24h Format
The time picker supports both 12-hour (with AM/PM toggle) and 24-hour formats.
States
Time pickers can show disabled or invalid states.
Minute Step Intervals
Configure custom minute increment steps (e.g., step={15} for quarter-hour slots).
Clicking arrows increments by 15 minutes.
Scrollable Wheel Time Selector
Render intuitive scrollable wheel column selectors for picking hours, minutes, and period with useWheel={true}.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | "12:00 PM" | The current time value (e.g., "12:00 PM" or "23:00") |
onChange | (time: string) => void | - | Callback fired when the time value changes |
format | "12h" | "24h" | "12h" | The time format to display |
step | number | 1 | The minute step amount when using arrows |
size | "sm" | "md" | "lg" | "md" | The size of the time picker |
label | ReactNode | - | The label shown above the input |
description | ReactNode | - | Help text shown below the input |
isInvalid | boolean | false | Whether the input is in an error state |
isDisabled | boolean | false | Whether the input is disabled |