Web InnoventixFreeCode

Calendar Time Picker

Original · free

time picker with hour/minute

byWeb InnoventixReact + Tailwind
calendartimepickercalendars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/calendar-time-picker.json
calendar-time-picker.tsx
"use client";

import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Period = "AM" | "PM";
type MinuteStep = 1 | 5 | 15;

type Preset = {
  key: string;
  label: string;
  note: string;
  hour24: number;
  minute: number;
};

const PRESETS: Preset[] = [
  { key: "early", label: "Early bird", note: "08:30", hour24: 8, minute: 30 },
  { key: "mid", label: "Mid-morning", note: "10:00", hour24: 10, minute: 0 },
  { key: "lunch", label: "After lunch", note: "13:30", hour24: 13, minute: 30 },
  { key: "late", label: "Golden hour", note: "16:45", hour24: 16, minute: 45 },
];

const RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";

function ChevronUp() {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true" fill="none">
      <path
        d="M6 15l6-6 6 6"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ChevronDown() {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true" fill="none">
      <path
        d="M6 9l6 6 6-6"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function Segmented<T extends string>({
  label,
  options,
  value,
  onChange,
}: {
  label: string;
  options: readonly { value: T; label: string }[];
  value: T;
  onChange: (v: T) => void;
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const idx = options.findIndex((o) => o.value === value);

  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown")
      next = (idx + 1) % options.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (idx - 1 + options.length) % options.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = options.length - 1;
    if (next === -1) return;
    e.preventDefault();
    onChange(options[next].value);
    refs.current[next]?.focus();
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      onKeyDown={onKey}
      className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-slate-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900"
    >
      {options.map((o, i) => {
        const active = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(o.value)}
            className={`rounded-lg px-3 py-1.5 text-sm font-semibold tabular-nums transition-colors ${RING} ${
              active
                ? "bg-white text-indigo-700 shadow-sm dark:bg-zinc-800 dark:text-indigo-300"
                : "text-slate-500 hover:text-slate-800 dark:text-zinc-400 dark:hover:text-zinc-200"
            }`}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

function WheelColumn({
  label,
  items,
  value,
  valueText,
  format,
  onSelect,
}: {
  label: string;
  items: number[];
  value: number;
  valueText: string;
  format: (n: number) => string;
  onSelect: (v: number) => void;
}) {
  const n = items.length;
  const idx = Math.max(0, items.indexOf(value));
  const rows = [-2, -1, 0, 1, 2];

  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    let ni = -1;
    switch (e.key) {
      case "ArrowUp":
        ni = (idx + 1) % n;
        break;
      case "ArrowDown":
        ni = (idx - 1 + n) % n;
        break;
      case "PageUp":
        ni = (idx + Math.min(5, n)) % n;
        break;
      case "PageDown":
        ni = (idx - Math.min(5, n) + n * 5) % n;
        break;
      case "Home":
        ni = 0;
        break;
      case "End":
        ni = n - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    onSelect(items[ni]);
  };

  return (
    <div className="flex flex-col items-center gap-1.5">
      <button
        type="button"
        aria-label={`Increase ${label}`}
        onClick={() => onSelect(items[(idx + 1) % n])}
        className={`flex h-8 w-11 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-indigo-600 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-indigo-400 ${RING}`}
      >
        <ChevronUp />
      </button>

      <div
        role="spinbutton"
        tabIndex={0}
        aria-label={label}
        aria-valuemin={items[0]}
        aria-valuemax={items[n - 1]}
        aria-valuenow={value}
        aria-valuetext={valueText}
        onKeyDown={onKey}
        className={`relative h-[168px] w-[84px] overflow-hidden rounded-2xl border border-slate-200 bg-gradient-to-b from-white to-slate-50 dark:border-zinc-800 dark:from-zinc-900 dark:to-zinc-950 ${RING}`}
      >
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-2 top-[66px] h-9 rounded-lg border border-indigo-200 bg-indigo-50/80 dark:border-indigo-500/30 dark:bg-indigo-500/10"
        />
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 top-0 z-10 h-9 bg-gradient-to-b from-white to-transparent dark:from-zinc-900"
        />
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-9 bg-gradient-to-t from-slate-50 to-transparent dark:from-zinc-950"
        />
        <div className="relative flex flex-col">
          {rows.map((pos) => {
            const vi = (idx - pos + n * 10) % n;
            const dist = Math.abs(pos);
            const center = dist === 0;
            return (
              <button
                key={pos}
                type="button"
                tabIndex={-1}
                aria-hidden="true"
                onClick={() => onSelect(items[vi])}
                style={{ opacity: center ? 1 : dist === 1 ? 0.5 : 0.2 }}
                className={`flex h-9 items-center justify-center text-2xl tabular-nums transition-opacity ${
                  center
                    ? "font-semibold text-indigo-700 dark:text-indigo-300"
                    : "font-medium text-slate-500 dark:text-zinc-400"
                }`}
              >
                {format(items[vi])}
              </button>
            );
          })}
        </div>
      </div>

      <button
        type="button"
        aria-label={`Decrease ${label}`}
        onClick={() => onSelect(items[(idx - 1 + n) % n])}
        className={`flex h-8 w-11 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-indigo-600 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-indigo-400 ${RING}`}
      >
        <ChevronDown />
      </button>
      <span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-zinc-500">
        {label}
      </span>
    </div>
  );
}

export default function CalendarTimePicker() {
  const reduce = useReducedMotion();

  const [hour24, setHour24] = useState(9);
  const [minute, setMinute] = useState(30);
  const [is24h, setIs24h] = useState(false);
  const [step, setStep] = useState<MinuteStep>(5);
  const [confirmed, setConfirmed] = useState(false);

  const period: Period = hour24 < 12 ? "AM" : "PM";
  const displayHour = ((hour24 + 11) % 12) + 1;
  const mm = String(minute).padStart(2, "0");

  const hourItems = useMemo(
    () =>
      is24h
        ? Array.from({ length: 24 }, (_, i) => i)
        : Array.from({ length: 12 }, (_, i) => i + 1),
    [is24h],
  );
  const minuteItems = useMemo(() => {
    const arr: number[] = [];
    for (let m = 0; m < 60; m += step) arr.push(m);
    return arr;
  }, [step]);

  const hourValue = is24h ? hour24 : displayHour;

  const touch = () => setConfirmed(false);

  const handleHour = (v: number) => {
    touch();
    setHour24(is24h ? v : (v % 12) + (period === "PM" ? 12 : 0));
  };
  const handleMinute = (v: number) => {
    touch();
    setMinute(v);
  };
  const handlePeriod = (p: Period) => {
    touch();
    if (p === "PM" && hour24 < 12) setHour24(hour24 + 12);
    else if (p === "AM" && hour24 >= 12) setHour24(hour24 - 12);
  };
  const handleFormat = (v: "12" | "24") => {
    touch();
    setIs24h(v === "24");
  };
  const handleStep = (v: MinuteStep) => {
    touch();
    setStep(v);
    setMinute((prev) => Math.min(Math.round(prev / v) * v, 60 - v));
  };
  const applyPreset = (p: Preset) => {
    touch();
    setHour24(p.hour24);
    setMinute(p.minute);
  };

  const timeLabel = is24h
    ? `${String(hour24).padStart(2, "0")}:${mm}`
    : `${displayHour}:${mm} ${period}`;
  const liveText = is24h
    ? `${String(hour24).padStart(2, "0")}:${mm} selected`
    : `${displayHour} ${mm} ${period} selected`;

  const minuteAngle = minute * 6;
  const hourAngle = (hour24 % 12) * 30 + minute * 0.5;
  const handTransition = reduce
    ? "none"
    : "transform 0.55s cubic-bezier(0.22, 1, 0.36, 1)";

  const ticks = Array.from({ length: 12 }, (_, i) => i);
  const numbers = Array.from({ length: 12 }, (_, i) => i + 1);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 dark:bg-zinc-950 dark:text-slate-100">
      <style>{`
        @keyframes ctp-pulse {
          0%, 100% { opacity: 0.35; transform: scale(1); }
          50% { opacity: 0.12; transform: scale(1.12); }
        }
        @keyframes ctp-rise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .ctp-pulse-anim { animation: ctp-pulse 3.6s ease-in-out infinite; }
        .ctp-rise-anim { animation: ctp-rise 0.5s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .ctp-pulse-anim, .ctp-rise-anim { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-4xl">
        <header className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="mb-1 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              Step 2 of 3
            </p>
            <h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
              Book your discovery call
            </h2>
            <p className="mt-1 max-w-md text-sm text-slate-500 dark:text-zinc-400">
              Choose a start time — all slots show in your local timezone.
            </p>
          </div>
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            GMT+5 · Karachi
          </span>
        </header>

        <div className="grid gap-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-200/50 sm:p-8 md:grid-cols-[minmax(0,320px)_1fr] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          {/* Clock + digital preview */}
          <div className="flex flex-col items-center justify-center gap-6 rounded-2xl bg-gradient-to-br from-indigo-50 to-violet-50 p-6 dark:from-indigo-950/40 dark:to-violet-950/30">
            <div className="relative flex h-56 w-56 items-center justify-center">
              <div className="ctp-pulse-anim absolute h-44 w-44 rounded-full bg-indigo-400/30 blur-2xl dark:bg-indigo-500/20" />
              <svg
                viewBox="0 0 220 220"
                className="relative h-56 w-56"
                role="img"
                aria-label={`Clock showing ${timeLabel}`}
              >
                <circle
                  cx="110"
                  cy="110"
                  r="98"
                  className="fill-white stroke-slate-200 dark:fill-zinc-950 dark:stroke-zinc-800"
                  strokeWidth="2"
                />
                <circle
                  cx="110"
                  cy="110"
                  r="88"
                  className="fill-transparent stroke-slate-100 dark:stroke-zinc-900"
                  strokeWidth="1"
                />
                {ticks.map((i) => {
                  const a = (i * 30 * Math.PI) / 180;
                  const outer = 92;
                  const inner = i % 3 === 0 ? 80 : 85;
                  return (
                    <line
                      key={i}
                      x1={110 + outer * Math.sin(a)}
                      y1={110 - outer * Math.cos(a)}
                      x2={110 + inner * Math.sin(a)}
                      y2={110 - inner * Math.cos(a)}
                      className={
                        i % 3 === 0
                          ? "stroke-slate-400 dark:stroke-zinc-500"
                          : "stroke-slate-300 dark:stroke-zinc-700"
                      }
                      strokeWidth={i % 3 === 0 ? 2.5 : 1.5}
                      strokeLinecap="round"
                    />
                  );
                })}
                {numbers.map((num) => {
                  const a = (num * 30 * Math.PI) / 180;
                  const r = 68;
                  return (
                    <text
                      key={num}
                      x={110 + r * Math.sin(a)}
                      y={110 - r * Math.cos(a) + 5}
                      textAnchor="middle"
                      className="fill-slate-500 text-[13px] font-semibold dark:fill-zinc-400"
                    >
                      {num}
                    </text>
                  );
                })}
                {/* hour hand */}
                <g
                  style={{
                    transformBox: "view-box",
                    transformOrigin: "110px 110px",
                    transform: `rotate(${hourAngle}deg)`,
                    transition: handTransition,
                  }}
                >
                  <line
                    x1="110"
                    y1="118"
                    x2="110"
                    y2="62"
                    className="stroke-slate-800 dark:stroke-slate-100"
                    strokeWidth="6"
                    strokeLinecap="round"
                  />
                </g>
                {/* minute hand */}
                <g
                  style={{
                    transformBox: "view-box",
                    transformOrigin: "110px 110px",
                    transform: `rotate(${minuteAngle}deg)`,
                    transition: handTransition,
                  }}
                >
                  <line
                    x1="110"
                    y1="120"
                    x2="110"
                    y2="40"
                    className="stroke-indigo-600 dark:stroke-indigo-400"
                    strokeWidth="4"
                    strokeLinecap="round"
                  />
                </g>
                <circle
                  cx="110"
                  cy="110"
                  r="6"
                  className="fill-indigo-600 dark:fill-indigo-400"
                />
                <circle cx="110" cy="110" r="2.5" className="fill-white dark:fill-zinc-950" />
              </svg>
            </div>

            <div className="text-center">
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.p
                  key={timeLabel}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                  transition={{ duration: 0.28, ease: "easeOut" }}
                  className="text-4xl font-bold tracking-tight tabular-nums"
                >
                  {timeLabel}
                </motion.p>
              </AnimatePresence>
              <p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">
                Thursday, July 16, 2026
              </p>
            </div>
          </div>

          {/* Controls */}
          <div className="flex flex-col gap-6">
            <div className="flex items-end justify-center gap-3 sm:gap-4">
              <WheelColumn
                label="Hours"
                items={hourItems}
                value={hourValue}
                valueText={
                  is24h ? `${hour24} hours` : `${displayHour} ${period}`
                }
                format={(v) => (is24h ? String(v).padStart(2, "0") : String(v))}
                onSelect={handleHour}
              />
              <span
                aria-hidden="true"
                className="pb-16 text-3xl font-bold text-slate-300 dark:text-zinc-700"
              >
                :
              </span>
              <WheelColumn
                label="Minutes"
                items={minuteItems}
                value={minute}
                valueText={`${mm} minutes`}
                format={(v) => String(v).padStart(2, "0")}
                onSelect={handleMinute}
              />
              {!is24h && (
                <div className="flex flex-col gap-2 pb-8 pl-1">
                  {(["AM", "PM"] as const).map((p) => {
                    const active = period === p;
                    return (
                      <button
                        key={p}
                        type="button"
                        role="radio"
                        aria-checked={active}
                        aria-label={`Set ${p}`}
                        onClick={() => handlePeriod(p)}
                        className={`w-12 rounded-xl border px-2 py-2.5 text-sm font-bold transition-colors ${RING} ${
                          active
                            ? "border-indigo-500 bg-indigo-600 text-white dark:border-indigo-400 dark:bg-indigo-500"
                            : "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:text-slate-800 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
                        }`}
                      >
                        {p}
                      </button>
                    );
                  })}
                </div>
              )}
            </div>

            <div className="flex flex-wrap items-center gap-x-6 gap-y-3">
              <label className="flex items-center gap-2.5">
                <span className="text-sm font-medium text-slate-600 dark:text-zinc-300">
                  24-hour
                </span>
                <button
                  type="button"
                  role="switch"
                  aria-checked={is24h}
                  aria-label="Use 24-hour format"
                  onClick={() => handleFormat(is24h ? "12" : "24")}
                  className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${RING} ${
                    is24h
                      ? "bg-indigo-600 dark:bg-indigo-500"
                      : "bg-slate-300 dark:bg-zinc-700"
                  }`}
                >
                  <span
                    className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
                      is24h ? "translate-x-6" : "translate-x-1"
                    }`}
                  />
                </button>
              </label>

              <div className="flex items-center gap-2.5">
                <span className="text-sm font-medium text-slate-600 dark:text-zinc-300">
                  Interval
                </span>
                <Segmented<string>
                  label="Minute interval"
                  options={[
                    { value: "1", label: "1m" },
                    { value: "5", label: "5m" },
                    { value: "15", label: "15m" },
                  ]}
                  value={String(step)}
                  onChange={(v) => handleStep(Number(v) as MinuteStep)}
                />
              </div>
            </div>

            <div>
              <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
                Popular slots
              </p>
              <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
                {PRESETS.map((p) => {
                  const active = p.hour24 === hour24 && p.minute === minute;
                  return (
                    <button
                      key={p.key}
                      type="button"
                      onClick={() => applyPreset(p)}
                      aria-pressed={active}
                      className={`flex flex-col items-start rounded-xl border px-3 py-2.5 text-left transition-colors ${RING} ${
                        active
                          ? "border-indigo-500 bg-indigo-50 dark:border-indigo-400/60 dark:bg-indigo-500/10"
                          : "border-slate-200 bg-white hover:border-indigo-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-indigo-500/40 dark:hover:bg-zinc-800/60"
                      }`}
                    >
                      <span className="text-sm font-semibold text-slate-800 dark:text-zinc-100">
                        {p.label}
                      </span>
                      <span className="text-xs tabular-nums text-slate-500 dark:text-zinc-400">
                        {p.note}
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>

            <div className="mt-auto flex flex-col gap-3 border-t border-slate-100 pt-5 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
              <AnimatePresence mode="wait" initial={false}>
                {confirmed ? (
                  <motion.p
                    key="done"
                    initial={reduce ? false : { opacity: 0, x: -6 }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={{ opacity: 0 }}
                    className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      className="h-4 w-4"
                      fill="none"
                      aria-hidden="true"
                    >
                      <path
                        d="M5 13l4 4L19 7"
                        stroke="currentColor"
                        strokeWidth="2.5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                    Locked in for {timeLabel} — invite sent to 3 guests.
                  </motion.p>
                ) : (
                  <motion.p
                    key="pending"
                    initial={reduce ? false : { opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    className="text-sm text-slate-500 dark:text-zinc-400"
                  >
                    Starts{" "}
                    <span className="font-semibold text-slate-800 tabular-nums dark:text-zinc-100">
                      {timeLabel}
                    </span>{" "}
                    · 45 min call
                  </motion.p>
                )}
              </AnimatePresence>
              <button
                type="button"
                onClick={() => setConfirmed(true)}
                disabled={confirmed}
                className={`inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 disabled:cursor-not-allowed disabled:bg-emerald-600 disabled:opacity-100 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:disabled:bg-emerald-600 ${RING}`}
              >
                {confirmed ? "Confirmed" : `Confirm ${timeLabel}`}
              </button>
            </div>
          </div>
        </div>
      </div>

      <span aria-live="polite" className="sr-only">
        {liveText}
      </span>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →