Bloom Logo

Time Picker

A specialized input component for selecting hours, minutes, and periods (AM/PM) with intuitive keyboard and mouse controls.

importimport { TimePicker } from "@/components/ui/timePicker/timePicker";
$npx @bloomui-react/cli add timePicker
timePicker.tsx
"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.

variant: default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlined
:
:
:
:
:
:
:
:

Sizes

Available in three sizes: small, medium, and large.

size: sm | md | lg
:
:
:

Required State

Displays an asterisk next to the label indicating that choosing a time is mandatory.

isRequired: boolean
:

12h vs 24h Format

The time picker supports both 12-hour (with AM/PM toggle) and 24-hour formats.

format: anyh: any
:
:

States

Time pickers can show disabled or invalid states.

isDisabled: anyisInvalid: any
:
:

Minute Step Intervals

Configure custom minute increment steps (e.g., step={15} for quarter-hour slots).

step: number
:

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}.

useWheel: booleanstep: number
Hours
:
Minutes
Period
API Reference
PropTypeDefaultDescription
valuestring"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
stepnumber1The minute step amount when using arrows
size"sm" | "md" | "lg""md"The size of the time picker
labelReactNode-The label shown above the input
descriptionReactNode-Help text shown below the input
isInvalidbooleanfalseWhether the input is in an error state
isDisabledbooleanfalseWhether the input is disabled