Web InnoventixFreeCode

Calendar Datetime

Original · free

combined date and time picker

byWeb InnoventixReact + Tailwind
calendardatetimecalendars
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-datetime.json
calendar-datetime.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

/* ---------- date helpers (no external libs) ---------- */

const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const WEEKDAY_LONG = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

function startOfDay(d: Date): Date {
  const x = new Date(d);
  x.setHours(0, 0, 0, 0);
  return x;
}

function addDays(d: Date, n: number): Date {
  const x = new Date(d);
  x.setDate(x.getDate() + n);
  return x;
}

function addMonths(d: Date, n: number): Date {
  const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
  const dim = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
  target.setDate(Math.min(d.getDate(), dim));
  target.setHours(0, 0, 0, 0);
  return target;
}

function isSameDay(a: Date, b: Date): boolean {
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

function keyFor(d: Date): string {
  return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}

const dateFmt = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
  year: "numeric",
});
const monthFmt = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" });
const timeFmt = new Intl.DateTimeFormat("en-US", {
  hour: "numeric",
  minute: "2-digit",
  hour12: true,
});

/* ---------- inline icons ---------- */

type IconProps = { className?: string };

function ChevronLeft({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m15 18-6-6 6-6" />
    </svg>
  );
}
function ChevronRight({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m9 18 6-6-6-6" />
    </svg>
  );
}
function ChevronUp({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m18 15-6-6-6 6" />
    </svg>
  );
}
function ChevronDown({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}
function CalendarIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="4" width="18" height="18" rx="2" />
      <path d="M16 2v4M8 2v4M3 10h18" />
    </svg>
  );
}
function ClockIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7v5l3 2" />
    </svg>
  );
}
function CheckIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

/* ---------- component ---------- */

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

  // Fixed reference "today" keeps the demo deterministic (no SSR/hydration drift).
  const today = useMemo(() => startOfDay(new Date(2026, 6, 16)), []);

  const [selected, setSelected] = useState<Date>(() => new Date(2026, 6, 16, 14, 30, 0, 0));
  const [view, setView] = useState<{ year: number; month: number }>(() => ({
    year: 2026,
    month: 6,
  }));
  const [focusedDay, setFocusedDay] = useState<Date>(() => new Date(2026, 6, 16));
  const [direction, setDirection] = useState<number>(0);
  const [confirmed, setConfirmed] = useState<boolean>(false);
  const [tzLabel, setTzLabel] = useState<string>("");

  const gridRef = useRef<HTMLDivElement | null>(null);
  const shouldFocusRef = useRef<boolean>(false);
  const amRef = useRef<HTMLButtonElement | null>(null);
  const pmRef = useRef<HTMLButtonElement | null>(null);

  useEffect(() => {
    try {
      const parts = new Intl.DateTimeFormat(undefined, {
        timeZoneName: "short",
        hour: "2-digit",
      }).formatToParts(new Date());
      const zone = parts.find((p) => p.type === "timeZoneName");
      if (zone) setTzLabel(zone.value);
    } catch {
      /* ignore */
    }
  }, []);

  /* build a fixed 6x7 grid for the visible month */
  const weeks = useMemo(() => {
    const first = new Date(view.year, view.month, 1);
    const gridStart = addDays(first, -first.getDay());
    const rows: Date[][] = [];
    for (let r = 0; r < 6; r++) {
      const row: Date[] = [];
      for (let c = 0; c < 7; c++) row.push(addDays(gridStart, r * 7 + c));
      rows.push(row);
    }
    return rows;
  }, [view]);

  /* keep the roving-tabindex target inside the visible month */
  useEffect(() => {
    setFocusedDay((fd) => {
      if (fd.getFullYear() === view.year && fd.getMonth() === view.month) return fd;
      const dim = new Date(view.year, view.month + 1, 0).getDate();
      return new Date(view.year, view.month, Math.min(fd.getDate(), dim));
    });
  }, [view]);

  /* move DOM focus to the roving day when a keyboard action asked for it */
  useEffect(() => {
    if (!shouldFocusRef.current) return;
    const el = gridRef.current?.querySelector<HTMLButtonElement>(
      `[data-day="${keyFor(focusedDay)}"]`,
    );
    if (el) {
      el.focus();
      shouldFocusRef.current = false;
    }
  }, [focusedDay, view]);

  const moveFocus = useCallback(
    (next: Date) => {
      shouldFocusRef.current = true;
      setFocusedDay(next);
      if (next.getMonth() !== view.month || next.getFullYear() !== view.year) {
        setDirection(next.getTime() > focusedDay.getTime() ? 1 : -1);
        setView({ year: next.getFullYear(), month: next.getMonth() });
      }
    },
    [view, focusedDay],
  );

  const shiftMonth = useCallback((delta: number) => {
    setDirection(delta);
    setView((v) => {
      const m = v.month + delta;
      return {
        year: v.year + Math.floor(m / 12),
        month: ((m % 12) + 12) % 12,
      };
    });
  }, []);

  const selectDay = useCallback((day: Date) => {
    setSelected((prev) => {
      const next = new Date(day);
      next.setHours(prev.getHours(), prev.getMinutes(), 0, 0);
      return next;
    });
    setFocusedDay(day);
    setConfirmed(false);
  }, []);

  const goToday = useCallback(() => {
    setSelected((prev) => {
      const next = new Date(today);
      next.setHours(prev.getHours(), prev.getMinutes(), 0, 0);
      return next;
    });
    setDirection(0);
    setView({ year: today.getFullYear(), month: today.getMonth() });
    setFocusedDay(new Date(today));
    shouldFocusRef.current = true;
    setConfirmed(false);
  }, [today]);

  const onDayKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLButtonElement>, day: Date) => {
      switch (e.key) {
        case "ArrowLeft":
          e.preventDefault();
          moveFocus(addDays(day, -1));
          break;
        case "ArrowRight":
          e.preventDefault();
          moveFocus(addDays(day, 1));
          break;
        case "ArrowUp":
          e.preventDefault();
          moveFocus(addDays(day, -7));
          break;
        case "ArrowDown":
          e.preventDefault();
          moveFocus(addDays(day, 7));
          break;
        case "Home":
          e.preventDefault();
          moveFocus(addDays(day, -day.getDay()));
          break;
        case "End":
          e.preventDefault();
          moveFocus(addDays(day, 6 - day.getDay()));
          break;
        case "PageUp":
          e.preventDefault();
          moveFocus(addMonths(day, -1));
          break;
        case "PageDown":
          e.preventDefault();
          moveFocus(addMonths(day, 1));
          break;
        case "Enter":
        case " ":
          e.preventDefault();
          selectDay(day);
          break;
        default:
          break;
      }
    },
    [moveFocus, selectDay],
  );

  /* ---- time ---- */

  const hours24 = selected.getHours();
  const minutes = selected.getMinutes();
  const meridiem: "AM" | "PM" = hours24 < 12 ? "AM" : "PM";
  const displayHour = ((hours24 + 11) % 12) + 1;

  const updateTime = useCallback((h24: number, m: number) => {
    setSelected((prev) => {
      const next = new Date(prev);
      next.setHours(h24, m, 0, 0);
      return next;
    });
    setConfirmed(false);
  }, []);

  const setHour12 = useCallback(
    (dh: number) => {
      const h24 = (dh % 12) + (meridiem === "PM" ? 12 : 0);
      updateTime(h24, minutes);
    },
    [meridiem, minutes, updateTime],
  );

  const stepHour = useCallback(
    (delta: number) => {
      setHour12(((displayHour - 1 + delta + 12) % 12) + 1);
    },
    [displayHour, setHour12],
  );

  const stepMinute = useCallback(
    (delta: number) => {
      updateTime(hours24, (minutes + delta + 60) % 60);
    },
    [hours24, minutes, updateTime],
  );

  const setMeridiemVal = useCallback(
    (mer: "AM" | "PM") => {
      if (mer === meridiem) return;
      const h24 = mer === "PM" ? (hours24 % 12) + 12 : hours24 % 12;
      updateTime(h24, minutes);
    },
    [meridiem, hours24, minutes, updateTime],
  );

  const onHourKey = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      switch (e.key) {
        case "ArrowUp":
        case "ArrowRight":
        case "PageUp":
          e.preventDefault();
          stepHour(1);
          break;
        case "ArrowDown":
        case "ArrowLeft":
        case "PageDown":
          e.preventDefault();
          stepHour(-1);
          break;
        case "Home":
          e.preventDefault();
          setHour12(12);
          break;
        case "End":
          e.preventDefault();
          setHour12(11);
          break;
        default:
          break;
      }
    },
    [stepHour, setHour12],
  );

  const onMinuteKey = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      switch (e.key) {
        case "ArrowUp":
        case "ArrowRight":
          e.preventDefault();
          stepMinute(1);
          break;
        case "ArrowDown":
        case "ArrowLeft":
          e.preventDefault();
          stepMinute(-1);
          break;
        case "PageUp":
          e.preventDefault();
          stepMinute(5);
          break;
        case "PageDown":
          e.preventDefault();
          stepMinute(-5);
          break;
        case "Home":
          e.preventDefault();
          updateTime(hours24, 0);
          break;
        case "End":
          e.preventDefault();
          updateTime(hours24, 59);
          break;
        default:
          break;
      }
    },
    [stepMinute, updateTime, hours24],
  );

  const onMeridiemKey = useCallback(
    (e: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (
        e.key === "ArrowUp" ||
        e.key === "ArrowDown" ||
        e.key === "ArrowLeft" ||
        e.key === "ArrowRight"
      ) {
        e.preventDefault();
        const next = meridiem === "AM" ? "PM" : "AM";
        setMeridiemVal(next);
        (next === "AM" ? amRef : pmRef).current?.focus();
      }
    },
    [meridiem, setMeridiemVal],
  );

  const timePresets: { label: string; h: number; m: number }[] = [
    { label: "9:00 AM", h: 9, m: 0 },
    { label: "12:30 PM", h: 12, m: 30 },
    { label: "3:15 PM", h: 15, m: 15 },
    { label: "5:45 PM", h: 17, m: 45 },
  ];

  const monthVariants = reduce
    ? {
        enter: { opacity: 0 },
        center: { opacity: 1 },
      }
    : {
        enter: (dir: number) => ({ opacity: 0, x: dir >= 0 ? 22 : -22 }),
        center: { opacity: 1, x: 0 },
      };

  const summary = `${dateFmt.format(selected)} at ${timeFmt.format(selected)}`;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-slate-50 to-slate-100 px-4 py-16 sm:px-6 sm:py-20 lg:py-24 dark:from-zinc-950 dark:via-zinc-950 dark:to-black">
      <style>{`
        @keyframes cdt-rise {
          0% { opacity: 0; transform: translateY(14px); }
          100% { opacity: 1; transform: translateY(0); }
        }
        @keyframes cdt-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.55; transform: scale(1.35); }
        }
        @keyframes cdt-check {
          0% { opacity: 0; transform: scale(0.4) rotate(-12deg); }
          60% { transform: scale(1.15) rotate(0deg); }
          100% { opacity: 1; transform: scale(1) rotate(0deg); }
        }
        .cdt-rise { animation: cdt-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .cdt-pulse { animation: cdt-pulse 2.4s ease-in-out infinite; }
        .cdt-check { animation: cdt-check 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .cdt-rise, .cdt-pulse, .cdt-check { animation: none !important; }
        }
      `}</style>

      {/* soft ambient blobs */}
      <div aria-hidden="true" className="pointer-events-none absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-600/10" />
      <div aria-hidden="true" className="pointer-events-none absolute -right-20 bottom-0 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-600/10" />

      <div className="relative mx-auto w-full max-w-3xl">
        <header className="mb-8 flex flex-col items-start gap-3">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <CalendarIcon className="h-3.5 w-3.5" />
            Scheduling
          </span>
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Pick a date &amp; time
          </h2>
          <p className="max-w-md text-sm text-slate-500 dark:text-zinc-400">
            Choose when the design review with the Northwind team should happen. Use the
            arrow keys inside the calendar and the time steppers.
          </p>
        </header>

        <div className="cdt-rise overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          <div className="h-1 w-full bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-500" />

          <div className="flex flex-col md:flex-row">
            {/* ---------------- calendar ---------------- */}
            <div className="p-5 sm:p-6 md:flex-1">
              <div className="mb-4 flex items-center justify-between">
                <button
                  type="button"
                  onClick={() => shiftMonth(-1)}
                  aria-label="Previous month"
                  className="flex h-9 w-9 items-center justify-center rounded-xl text-slate-500 outline-none transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:ring-offset-zinc-900"
                >
                  <ChevronLeft className="h-5 w-5" />
                </button>
                <div aria-live="polite" className="text-sm font-semibold text-slate-900 tabular-nums dark:text-white">
                  {monthFmt.format(new Date(view.year, view.month, 1))}
                </div>
                <button
                  type="button"
                  onClick={() => shiftMonth(1)}
                  aria-label="Next month"
                  className="flex h-9 w-9 items-center justify-center rounded-xl text-slate-500 outline-none transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-white dark:focus-visible:ring-offset-zinc-900"
                >
                  <ChevronRight className="h-5 w-5" />
                </button>
              </div>

              <div ref={gridRef} role="grid" aria-label="Choose a date" className="select-none">
                <div role="row" className="grid grid-cols-7 gap-1">
                  {WEEKDAY_SHORT.map((w, i) => (
                    <span
                      key={w}
                      role="columnheader"
                      aria-label={WEEKDAY_LONG[i]}
                      className="flex h-8 items-center justify-center text-xs font-medium text-slate-400 dark:text-zinc-500"
                    >
                      {w}
                    </span>
                  ))}
                </div>

                <motion.div
                  key={`${view.year}-${view.month}`}
                  role="rowgroup"
                  custom={direction}
                  variants={monthVariants}
                  initial="enter"
                  animate="center"
                  transition={{ duration: reduce ? 0 : 0.26, ease: "easeOut" }}
                  className="mt-1 space-y-1"
                >
                  {weeks.map((week, wi) => (
                    <div key={wi} role="row" className="grid grid-cols-7 gap-1">
                      {week.map((day) => {
                        const outside = day.getMonth() !== view.month;
                        const isSelected = isSameDay(day, selected);
                        const isToday = isSameDay(day, today);
                        const isRoving = isSameDay(day, focusedDay);

                        let tone =
                          "text-slate-700 hover:bg-slate-100 dark:text-zinc-200 dark:hover:bg-zinc-800";
                        if (isSelected) {
                          tone =
                            "bg-indigo-600 text-white shadow-sm shadow-indigo-600/40 hover:bg-indigo-600";
                        } else if (outside) {
                          tone =
                            "text-slate-300 hover:bg-slate-100 dark:text-zinc-600 dark:hover:bg-zinc-800/60";
                        }
                        const ring =
                          isToday && !isSelected
                            ? " ring-1 ring-inset ring-indigo-400/60 dark:ring-indigo-400/50"
                            : "";

                        return (
                          <div key={keyFor(day)} role="gridcell" aria-selected={isSelected}>
                            <button
                              type="button"
                              data-day={keyFor(day)}
                              tabIndex={isRoving ? 0 : -1}
                              onClick={() => selectDay(day)}
                              onKeyDown={(e) => onDayKeyDown(e, day)}
                              aria-label={dateFmt.format(day)}
                              aria-current={isToday ? "date" : undefined}
                              className={`relative flex h-10 w-full items-center justify-center rounded-xl text-sm font-medium outline-none transition-colors 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-900 ${tone}${ring}`}
                            >
                              {day.getDate()}
                              {isToday && (
                                <span
                                  aria-hidden="true"
                                  className={`cdt-pulse absolute bottom-1.5 h-1 w-1 rounded-full ${
                                    isSelected ? "bg-white" : "bg-indigo-500 dark:bg-indigo-400"
                                  }`}
                                />
                              )}
                            </button>
                          </div>
                        );
                      })}
                    </div>
                  ))}
                </motion.div>
              </div>

              <div className="mt-4 flex justify-start">
                <button
                  type="button"
                  onClick={goToday}
                  className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-600 outline-none transition-colors hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:bg-indigo-500/10 dark:focus-visible:ring-offset-zinc-900"
                >
                  <CalendarIcon className="h-3.5 w-3.5" />
                  Jump to today
                </button>
              </div>
            </div>

            {/* ---------------- time ---------------- */}
            <div className="border-t border-slate-200 p-5 sm:p-6 md:w-64 md:border-l md:border-t-0 dark:border-zinc-800">
              <div className="mb-4 flex items-center justify-between">
                <h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-slate-900 dark:text-white">
                  <ClockIcon className="h-4 w-4 text-slate-400 dark:text-zinc-500" />
                  Time
                </h3>
              </div>

              <div className="flex items-stretch justify-center gap-2">
                {/* hour */}
                <div className="flex flex-col items-center gap-1">
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-hidden="true"
                    onClick={() => stepHour(1)}
                    className="flex h-7 w-12 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                  >
                    <ChevronUp className="h-4 w-4" />
                  </button>
                  <div
                    role="spinbutton"
                    tabIndex={0}
                    aria-label="Hour"
                    aria-valuemin={1}
                    aria-valuemax={12}
                    aria-valuenow={displayHour}
                    aria-valuetext={`${displayHour} ${meridiem}`}
                    onKeyDown={onHourKey}
                    className="flex h-14 w-12 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-2xl font-semibold tabular-nums text-slate-900 outline-none transition-colors focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus-visible:ring-offset-zinc-900"
                  >
                    {displayHour}
                  </div>
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-hidden="true"
                    onClick={() => stepHour(-1)}
                    className="flex h-7 w-12 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                  >
                    <ChevronDown className="h-4 w-4" />
                  </button>
                </div>

                <span aria-hidden="true" className="flex items-center pb-0 text-2xl font-semibold text-slate-300 dark:text-zinc-600">
                  :
                </span>

                {/* minute */}
                <div className="flex flex-col items-center gap-1">
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-hidden="true"
                    onClick={() => stepMinute(1)}
                    className="flex h-7 w-12 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                  >
                    <ChevronUp className="h-4 w-4" />
                  </button>
                  <div
                    role="spinbutton"
                    tabIndex={0}
                    aria-label="Minute"
                    aria-valuemin={0}
                    aria-valuemax={59}
                    aria-valuenow={minutes}
                    aria-valuetext={`${String(minutes).padStart(2, "0")} minutes`}
                    onKeyDown={onMinuteKey}
                    className="flex h-14 w-12 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-2xl font-semibold tabular-nums text-slate-900 outline-none transition-colors focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus-visible:ring-offset-zinc-900"
                  >
                    {String(minutes).padStart(2, "0")}
                  </div>
                  <button
                    type="button"
                    tabIndex={-1}
                    aria-hidden="true"
                    onClick={() => stepMinute(-1)}
                    className="flex h-7 w-12 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                  >
                    <ChevronDown className="h-4 w-4" />
                  </button>
                </div>

                {/* meridiem */}
                <div role="radiogroup" aria-label="AM or PM" className="flex flex-col justify-center gap-1 pl-1">
                  {(["AM", "PM"] as const).map((m) => {
                    const checked = meridiem === m;
                    return (
                      <button
                        key={m}
                        ref={m === "AM" ? amRef : pmRef}
                        type="button"
                        role="radio"
                        aria-checked={checked}
                        tabIndex={checked ? 0 : -1}
                        onClick={() => setMeridiemVal(m)}
                        onKeyDown={onMeridiemKey}
                        className={`rounded-lg px-3 py-1.5 text-xs font-semibold outline-none transition-colors 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-900 ${
                          checked
                            ? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/40"
                            : "border border-slate-200 text-slate-600 hover:bg-slate-100 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
                        }`}
                      >
                        {m}
                      </button>
                    );
                  })}
                </div>
              </div>

              <div className="mt-6">
                <p className="mb-2 text-xs font-medium text-slate-400 dark:text-zinc-500">
                  Quick times
                </p>
                <div className="grid grid-cols-2 gap-2">
                  {timePresets.map((p) => {
                    const active = hours24 === p.h && minutes === p.m;
                    return (
                      <button
                        key={p.label}
                        type="button"
                        onClick={() => updateTime(p.h, p.m)}
                        aria-pressed={active}
                        className={`rounded-xl border px-2 py-2 text-xs font-medium tabular-nums outline-none transition-colors 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-900 ${
                          active
                            ? "border-indigo-500 bg-indigo-50 text-indigo-700 dark:border-indigo-500/60 dark:bg-indigo-500/10 dark:text-indigo-300"
                            : "border-slate-200 text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
                        }`}
                      >
                        {p.label}
                      </button>
                    );
                  })}
                </div>
              </div>
            </div>
          </div>

          {/* ---------------- footer summary ---------------- */}
          <div className="flex flex-col gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-zinc-800 dark:bg-zinc-950/60">
            <div className="flex items-center gap-3">
              <div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-white text-indigo-600 shadow-sm ring-1 ring-slate-200 dark:bg-zinc-900 dark:text-indigo-400 dark:ring-zinc-800">
                <CalendarIcon className="h-5 w-5" />
              </div>
              <div className="min-w-0">
                <div className="truncate text-sm font-semibold text-slate-900 dark:text-white">
                  {dateFmt.format(selected)}
                </div>
                <div className="text-xs text-slate-500 tabular-nums dark:text-zinc-400">
                  {timeFmt.format(selected)}
                  {tzLabel ? ` · ${tzLabel}` : ""}
                </div>
              </div>
            </div>

            <button
              type="button"
              onClick={() => setConfirmed(true)}
              className={`inline-flex flex-none items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-zinc-950 ${
                confirmed
                  ? "bg-emerald-600 text-white shadow-sm shadow-emerald-600/30 hover:bg-emerald-600"
                  : "bg-indigo-600 text-white shadow-sm shadow-indigo-600/30 hover:bg-indigo-500"
              }`}
            >
              {confirmed ? (
                <>
                  <CheckIcon className="cdt-check h-4 w-4" />
                  Confirmed
                </>
              ) : (
                "Confirm time"
              )}
            </button>
          </div>
        </div>

        <p className="sr-only" role="status" aria-live="polite">
          {confirmed ? `Confirmed for ${summary}` : `Selected ${summary}`}
        </p>
      </div>
    </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 →