formsclient

time picker

columns of hours and minutes, with am and pm if you want them. the value is always a plain “14:30”.

reminder

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add time-picker

or by hand: drop these files into components/ui and import from there. time picker leans on DatePicker.tsx, Floating.tsx, FieldMessage.tsx and useFloatingPanel.ts, so those come along too.

components/ui/TimePicker.tsxtsx
"use client";
import React, { useId, useRef, useState } from "react";
import { ClearKey } from "./DatePicker";
import { Portal } from "./Floating";
import { FieldLabel, FieldMessage } from "./FieldMessage";
import { useFloatingPanel } from "./useFloatingPanel";
interface TimePickerProps {
/** 24-hour "HH:mm", like "14:30" */
value?: string | null;
defaultValue?: string | null;
onChange?: (time: string | null) => void;
/** Minutes between choices in the minute column */
step?: number;
/** Show 1–12 with am / pm instead of 0–23 */
hour12?: boolean;
/** Earliest and latest times, as "HH:mm" */
min?: string;
max?: string;
label?: string;
hint?: string;
error?: string;
placeholder?: string;
clearable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
name?: string;
className?: string;
}
const pad = (n: number) => String(n).padStart(2, "0");
const toMinutes = (t?: string | null) => {
if (!t) return null;
const m = /^(\d{1,2}):(\d{2})$/.exec(t);
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
};
const fromMinutes = (m: number) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
export function formatTime(t: string, hour12: boolean) {
const m = toMinutes(t);
if (m === null) return t;
const h = Math.floor(m / 60);
return hour12 ? `${h % 12 || 12}:${pad(m % 60)} ${h < 12 ? "am" : "pm"}` : fromMinutes(m);
}
type Column = "hour" | "minute" | "period";
/* A field that opens a panel of columns: hours, minutes and, for a
12-hour clock, am / pm. The chosen cell in each is a raised key in the
inverse material. Arrows move within a column and between columns;
Enter or picking a minute closes it. */
export function TimePicker({
value,
defaultValue = null,
onChange,
step = 15,
hour12 = false,
min,
max,
label,
hint,
error,
placeholder = "pick a time",
clearable = false,
disabled = false,
fullWidth = false,
name,
className = "",
}: TimePickerProps) {
const uid = useId();
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [internal, setInternal] = useState<string | null>(defaultValue);
const current = value !== undefined ? value : internal;
const total = toMinutes(current);
const { open, mounted, toggle, close, setFloating, panelStyle } = useFloatingPanel(ref, {
onEscape: () => buttonRef.current?.focus(),
});
const messageId = error || hint ? `${uid}-message` : undefined;
const lo = toMinutes(min) ?? 0;
const hi = toMinutes(max) ?? 24 * 60 - 1;
const hour = total === null ? null : Math.floor(total / 60);
const minute = total === null ? null : total % 60;
const set = (t: string | null) => {
setInternal(t);
onChange?.(t);
};
/* Picks a time, nudging the minutes into range when the hour changes */
const choose = (h: number, m: number) => {
let t = h * 60 + m;
if (t < lo) t = lo;
if (t > hi) t = hi;
set(fromMinutes(t));
};
const hours = hour12 ? Array.from({ length: 12 }, (_, i) => (i === 0 ? 12 : i)) : Array.from({ length: 24 }, (_, i) => i);
const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((m) => m < 60);
const isPm = hour !== null && hour >= 12;
const to24 = (h12: number, pm: boolean) => (h12 % 12) + (pm ? 12 : 0);
const hourOff = (h24: number) => h24 * 60 + 59 < lo || h24 * 60 > hi;
const minuteOff = (m: number) => hour !== null && (hour * 60 + m < lo || hour * 60 + m > hi);
type Cell = { key: string; label: string; selected: boolean; off: boolean; pick: () => void };
const columns: { id: Column; label: string; cells: Cell[] }[] = [
{
id: "hour",
label: "hour",
cells: hours.map((h) => {
const h24 = hour12 ? to24(h, isPm) : h;
return {
key: `h${h}`,
label: hour12 ? String(h) : pad(h),
selected: hour === h24,
off: hourOff(h24),
pick: () => choose(h24, minute ?? 0),
};
}),
},
{
id: "minute",
label: "minute",
cells: minutes.map((m) => ({
key: `m${m}`,
label: pad(m),
selected: minute === m,
off: minuteOff(m),
pick: () => {
choose(hour ?? Math.floor(lo / 60), m);
close();
buttonRef.current?.focus();
},
})),
},
];
if (hour12) {
columns.push({
id: "period",
label: "am or pm",
cells: (["am", "pm"] as const).map((p) => {
const pm = p === "pm";
const h24 = to24(hour === null ? 12 : hour % 12 || 12, pm);
return {
key: p,
label: p,
selected: hour !== null && isPm === pm,
off: pm ? hi < 12 * 60 : lo >= 12 * 60,
pick: () => choose(h24, minute ?? 0),
};
}),
});
}
const colRefs = useRef<Record<string, HTMLDivElement | null>>({});
/* Each column scrolls its chosen cell into the middle when it mounts */
const attachColumn = (id: Column) => (el: HTMLDivElement | null) => {
colRefs.current[id] = el;
const sel = el?.querySelector<HTMLElement>('[aria-selected="true"]');
if (!el || el.dataset.scrolled) return;
el.dataset.scrolled = "1";
if (sel) el.scrollTop = sel.offsetTop - el.clientHeight / 2 + sel.offsetHeight / 2;
// The panel lives at the end of <body>, so focus goes to it rather than waiting for Tab
if (id === "hour") el.focus({ preventScroll: true });
};
const onColumnKey = (ci: number) => (e: React.KeyboardEvent) => {
const col = columns[ci];
const enabled = col.cells.map((c, i) => (c.off ? -1 : i)).filter((i) => i >= 0);
const at = col.cells.findIndex((c) => c.selected);
const pos = enabled.indexOf(at);
const focusCol = (n: number) =>
colRefs.current[columns[Math.max(0, Math.min(columns.length - 1, n))].id]?.focus();
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const d = e.key === "ArrowDown" ? 1 : -1;
const next = enabled[pos < 0 ? 0 : (pos + d + enabled.length) % enabled.length];
// the minute column closes on pick; from the keyboard, just move
if (col.id === "minute") choose(hour ?? Math.floor(lo / 60), minutes[next]);
else col.cells[next]?.pick();
requestAnimationFrame(() =>
colRefs.current[col.id]?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }),
);
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusCol(ci + 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
focusCol(ci - 1);
} else if (e.key === "Enter") {
e.preventDefault();
close();
buttonRef.current?.focus();
}
};
return (
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
{label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
<div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-44", className].join(" ")}>
{/* The field's error styling keys off aria-invalid, as Input's does */}
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-haspopup="dialog"
aria-expanded={open}
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
data-open={open || undefined}
className={[
"field group/time relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
clearable && current ? "pr-10" : "pr-4",
"focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
].join(" ")}
>
<svg
aria-hidden
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
viewBox="0 0 24 24"
className={[
"shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/time:text-ink-body",
].join(" ")}
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
<span id={`${uid}-value`} className={["tabular-nums", current ? "font-medium text-ink" : "text-ink-faint"].join(" ")}>
{current ? formatTime(current, hour12) : placeholder}
</span>
</button>
{clearable && current && !disabled && (
<ClearKey
label="clear time"
onClear={() => {
set(null);
buttonRef.current?.focus();
}}
/>
)}
{name && <input type="hidden" name={name} value={current ?? ""} />}
</div>
{mounted && (
<Portal>
<div
ref={setFloating}
role="dialog"
aria-label={label ? `choose ${label}` : "choose a time"}
className="rounded-[16px] p-1.5"
style={panelStyle}
>
<div className="flex gap-1 rounded-[11px] bg-panel-face p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
{columns.map((col, ci) => (
<div
key={col.id}
ref={attachColumn(col.id)}
role="listbox"
aria-label={col.label}
tabIndex={0}
onKeyDown={onColumnKey(ci)}
className="flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-[9px] p-0.5 scroll-thin outline-none focus-visible:ring-2 focus-visible:ring-ink"
style={{ scrollbarWidth: "none" }}
>
{col.cells.map((c) => (
<div
key={c.key}
role="option"
aria-selected={c.selected}
aria-disabled={c.off || undefined}
onClick={() => !c.off && c.pick()}
className={[
"grid h-8 shrink-0 place-items-center rounded-[8px] text-[13px] tabular-nums transition-colors duration-100",
c.off
? "cursor-not-allowed text-ink-ghost"
: c.selected
? "cursor-pointer font-semibold text-ink-inverse"
: "cursor-pointer text-ink-body hover:bg-surface-active hover:text-ink",
].join(" ")}
style={
c.selected && !c.off
? {
background: "var(--face-inverse)",
boxShadow: "var(--face-inverse-shadow)",
textShadow: "var(--emboss-inverse)",
}
: undefined
}
>
{c.label}
</div>
))}
</div>
))}
</div>
</div>
</Portal>
)}
<FieldMessage id={messageId} error={error} hint={hint} />
</div>
);
}
components/ui/DatePicker.tsxtsx
"use client";
import React, { useId, useMemo, useRef, useState } from "react";
import { Portal } from "./Floating";
import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";
import { useFloatingPanel } from "./useFloatingPanel";
/* ── Date helpers ─────────────────────────────────────────────────── */
/* Everything works on local calendar days at midnight, so a date never
drifts across a time zone boundary on its way through the picker. */
export const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const addMonths = (d: Date, n: number) => {
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));
};
const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();
export const sameDay = (a?: Date | null, b?: Date | null) =>
!!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
export const toISODate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
export interface DateRange {
from: Date | null;
to: Date | null;
}
/* ── Calendar ─────────────────────────────────────────────────────── */
interface CalendarProps {
value?: Date | null;
/** Shade a span of days instead of marking one (see DateRangePicker) */
range?: DateRange;
onChange?: (date: Date) => void;
/** The day under the pointer or keyboard, for previewing a range */
onDayHover?: (date: Date | null) => void;
/** The month shown first; defaults to the selected date, or today */
defaultMonth?: Date;
/** How many months sit side by side */
months?: 1 | 2;
min?: Date;
max?: Date;
/** Grey out and skip particular days, like weekends */
isDateDisabled?: (date: Date) => boolean;
/** 0 is Sunday, 1 is Monday */
weekStartsOn?: 0 | 1;
locale?: string;
/** Move focus onto the grid as soon as it mounts */
autoFocus?: boolean;
className?: string;
}
/* A month of days on the panel's face. The chosen day is a raised key in
the inverse material, today wears a small dot, and the grid follows
the ARIA date-grid pattern: arrows move by day and week, page keys by
month (shift for a year), home and end to the edges of the week. */
export function Calendar({
value,
range,
onChange,
onDayHover,
defaultMonth,
months = 1,
min,
max,
isDateDisabled,
weekStartsOn = 0,
locale = "en-US",
autoFocus = false,
className = "",
}: CalendarProps) {
const uid = useId();
const [today] = useState(() => startOfDay(new Date()));
const [focused, setFocused] = useState<Date>(() => startOfDay(value ?? range?.from ?? defaultMonth ?? today));
const [view, setView] = useState<Date>(() => startOfMonth(focused));
const [direction, setDirection] = useState<"left" | "right">("right");
// Set by keyboard moves, spent by the next day button that mounts focused
const pendingFocus = useRef(autoFocus);
// A value set from outside brings its month into view
const anchor = value ?? range?.from ?? null;
const [seen, setSeen] = useState(anchor?.getTime());
if (anchor?.getTime() !== seen) {
setSeen(anchor?.getTime());
if (anchor) {
const d = startOfDay(anchor);
setFocused(d);
const shown = monthIndex(d) - monthIndex(view);
if (shown < 0 || shown >= months) setView(startOfMonth(d));
}
}
const minDay = min && startOfDay(min);
const maxDay = max && startOfDay(max);
const disabled = (d: Date) =>
(minDay && d < minDay) || (maxDay && d > maxDay) || (isDateDisabled?.(d) ?? false);
const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);
const dayLabel = useMemo(
() => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
[locale],
);
const weekdays = useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
// 2023-01-01 was a Sunday
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
return { short: short.format(d).slice(0, 2), long: long.format(d) };
});
}, [locale, weekStartsOn]);
/* Moves the roving focus, scrolling the view when the day is off it */
const move = (next: Date, viaKeyboard: boolean) => {
pendingFocus.current = viaKeyboard;
const offset = monthIndex(next) - monthIndex(view);
if (offset < 0) {
setDirection("left");
setView(startOfMonth(next));
} else if (offset >= months) {
setDirection("right");
setView(startOfMonth(addMonths(next, -(months - 1))));
}
setFocused(next);
if (viaKeyboard) onDayHover?.(next);
};
const flip = (n: number) => {
pendingFocus.current = false;
setDirection(n > 0 ? "right" : "left");
setView(addMonths(view, n));
setFocused(addMonths(focused, n));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const dow = (focused.getDay() - weekStartsOn + 7) % 7;
const map: Record<string, () => Date> = {
ArrowLeft: () => addDays(focused, -1),
ArrowRight: () => addDays(focused, 1),
ArrowUp: () => addDays(focused, -7),
ArrowDown: () => addDays(focused, 7),
PageUp: () => addMonths(focused, e.shiftKey ? -12 : -1),
PageDown: () => addMonths(focused, e.shiftKey ? 12 : 1),
Home: () => addDays(focused, -dow),
End: () => addDays(focused, 6 - dow),
};
if (map[e.key]) {
e.preventDefault();
move(map[e.key](), true);
}
};
/* The focused day takes focus after it renders, but only when the
keyboard moved it: a click shouldn't yank focus around. */
const focusRef = (el: HTMLButtonElement | null) => {
if (!el || !pendingFocus.current) return;
pendingFocus.current = false;
el.focus({ preventScroll: true });
};
const prevDisabled = !!minDay && addDays(view, -1) < minDay;
const nextDisabled = !!maxDay && addMonths(view, months) > maxDay;
const from = range?.from ? startOfDay(range.from) : null;
const to = range?.to ? startOfDay(range.to) : null;
return (
<div
className={["flex flex-wrap justify-center gap-x-6 gap-y-4 select-none", className].join(" ")}
onKeyDown={onKeyDown}
onMouseLeave={() => onDayHover?.(null)}
>
{Array.from({ length: months }, (_, m) => {
const month = addMonths(view, m);
const first = startOfMonth(month);
const gridStart = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));
// Always six weeks, so the panel keeps its height from month to month
const weeks = Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)),
);
const labelId = `${uid}-month-${m}`;
return (
<div key={m} className="w-[266px]">
<div className="mb-2 flex h-7 items-center justify-between gap-2">
{m === 0 ? (
<FieldKey label="previous month" disabled={prevDisabled} onClick={() => flip(-1)}>
<path d="M15 19l-7-7 7-7" />
</FieldKey>
) : (
<span className="w-7" />
)}
<span id={labelId} aria-live="polite" className="text-[13.5px] font-semibold text-ink tracking-[-0.01em]">
{monthFmt.format(month)}
</span>
{m === months - 1 ? (
<FieldKey label="next month" disabled={nextDisabled} onClick={() => flip(1)}>
<path d="M9 5l7 7-7 7" />
</FieldKey>
) : (
<span className="w-7" />
)}
</div>
<div role="grid" aria-labelledby={labelId}>
<div role="row" className="grid grid-cols-7">
{weekdays.map((w) => (
<span
key={w.long}
role="columnheader"
aria-label={w.long}
className="grid h-7 place-items-center text-[11px] font-medium text-ink-faint"
>
{w.short}
</span>
))}
</div>
<div
key={monthIndex(month)}
className={direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left"}
>
{weeks.map((week, wi) => (
<div role="row" key={wi} className="grid grid-cols-7">
{week.map((d) => {
const inMonth = d.getMonth() === month.getMonth();
// With two months the spill-over days would show twice
if (!inMonth && months > 1) {
return <div role="gridcell" key={d.getTime()} className="h-[38px]" />;
}
const isEndpoint = range ? sameDay(d, from) || sameDay(d, to) : sameDay(d, value);
const inBand = !!(from && to && d > from && d < to);
const bandLeft = !!(from && to && sameDay(d, to) && !sameDay(from, to));
const bandRight = !!(from && to && sameDay(d, from) && !sameDay(from, to));
const isToday = sameDay(d, today);
const isOff = disabled(d);
const isFocus = sameDay(d, focused) && inMonth;
return (
<div
role="gridcell"
key={d.getTime()}
aria-selected={isEndpoint || inBand}
className="relative grid h-[38px] place-items-center"
>
{/* The span of a range, cut into the face like a well */}
{(inBand || bandLeft || bandRight) && (
<span
aria-hidden
className="absolute inset-y-[3px]"
style={{
left: bandLeft || inBand ? 0 : "50%",
right: bandRight || inBand ? 0 : "50%",
background: "var(--well)",
boxShadow: "var(--well-shadow)",
}}
/>
)}
<button
type="button"
ref={isFocus ? focusRef : undefined}
tabIndex={isFocus ? 0 : -1}
disabled={isOff}
aria-label={dayLabel.format(d)}
aria-current={isToday ? "date" : undefined}
onClick={() => {
move(d, false);
if (!isOff) onChange?.(d);
}}
onMouseEnter={() => onDayHover?.(d)}
className={[
"relative grid h-9 w-9 place-items-center rounded-[10px] text-[13px] tabular-nums",
"transition-[background-color,color,box-shadow] duration-150",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
"disabled:cursor-not-allowed disabled:text-ink-ghost disabled:line-through",
isEndpoint
? "font-semibold text-ink-inverse"
: [
"cursor-pointer hover:bg-surface-active",
!inMonth ? "text-ink-ghost" : isToday ? "font-semibold text-ink-strong" : "text-ink-soft",
].join(" "),
].join(" ")}
style={
isEndpoint
? {
background: "var(--face-inverse)",
boxShadow: "var(--face-inverse-shadow)",
textShadow: "var(--emboss-inverse)",
}
: undefined
}
>
{d.getDate()}
{isToday && (
<span
aria-hidden
className={[
"absolute bottom-[5px] left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full",
isEndpoint ? "bg-ink-inverse" : "bg-ink",
].join(" ")}
/>
)}
</button>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
}
/* ── Pieces both pickers share ────────────────────────────────────── */
export const CalendarIcon = ({ className = "" }: { className?: string }) => (
<svg aria-hidden width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" className={className}>
<rect x="3" y="5" width="18" height="16" rx="2.5" />
<path strokeLinecap="round" d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
export function ClearKey({ label, onClear }: { label: string; onClear: () => void }) {
return (
<button
type="button"
aria-label={label}
onClick={onClear}
className="absolute right-2.5 top-1/2 z-10 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded-full text-ink-faint transition-colors hover:bg-surface-active hover:text-ink-soft"
>
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
);
}
/* ── DatePicker ───────────────────────────────────────────────────── */
interface DatePickerProps {
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date | null) => void;
label?: string;
hint?: string;
error?: string;
placeholder?: string;
min?: Date;
max?: Date;
isDateDisabled?: (date: Date) => boolean;
weekStartsOn?: 0 | 1;
locale?: string;
/** How the chosen date reads in the field */
format?: Intl.DateTimeFormatOptions;
/** Submit the date with a form, as yyyy-mm-dd */
name?: string;
/** Show a small key in the field that empties it */
clearable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
}
const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
/* The closed picker is a sunken field like Select; the calendar opens
under it on a floating panel. Picking a day closes it and hands
focus back to the field. */
export function DatePicker({
value,
defaultValue = null,
onChange,
label,
hint,
error,
placeholder = "pick a date",
min,
max,
isDateDisabled,
weekStartsOn,
locale = "en-US",
format = DEFAULT_FORMAT,
name,
clearable = false,
disabled = false,
fullWidth = false,
className = "",
}: DatePickerProps) {
const uid = useId();
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [internal, setInternal] = useState<Date | null>(defaultValue);
const selected = value !== undefined ? value : internal;
const { open, mounted, toggle, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {
onEscape: () => buttonRef.current?.focus(),
});
const messageId = error || hint ? `${uid}-message` : undefined;
const text = selected ? new Intl.DateTimeFormat(locale, format).format(selected) : null;
const set = (d: Date | null) => {
setInternal(d);
onChange?.(d);
};
return (
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
{label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
<div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-56", className].join(" ")}>
{/* The field's error styling keys off aria-invalid, as Input's does */}
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={toggle}
onKeyDown={(e) => {
if (!open && e.key === "ArrowDown") {
e.preventDefault();
show();
}
}}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? `${uid}-panel` : undefined}
aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
data-open={open || undefined}
className={[
"field group/date relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
clearable && selected ? "pr-10" : "pr-4",
"focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
].join(" ")}
>
<CalendarIcon
className={[
"shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",
].join(" ")}
/>
<span
id={`${uid}-value`}
className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}
>
{text ?? placeholder}
</span>
</button>
{clearable && selected && !disabled && (
<ClearKey
label="clear date"
onClear={() => {
set(null);
buttonRef.current?.focus();
}}
/>
)}
{name && <input type="hidden" name={name} value={selected ? toISODate(selected) : ""} />}
</div>
{mounted && (
<Portal>
<div
ref={setFloating}
id={`${uid}-panel`}
role="dialog"
aria-label={label ? `choose ${label}` : "choose a date"}
className="rounded-[16px] p-1.5"
style={panelStyle}
>
<div className="rounded-[11px] bg-panel-face p-2.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
<Calendar
value={selected}
onChange={(d) => {
set(d);
close();
buttonRef.current?.focus();
}}
min={min}
max={max}
isDateDisabled={isDateDisabled}
weekStartsOn={weekStartsOn}
locale={locale}
autoFocus
/>
</div>
</div>
</Portal>
)}
<FieldMessage id={messageId} error={error} hint={hint} />
</div>
);
}
components/ui/Floating.tsxtsx
"use client";
import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/* ── Portal ───────────────────────────────────────────────────────── */
/* Anything that floats over the page is rendered at the end of <body>.
Left where it was declared, a panel lives inside whatever stacking
context its parents made (a transform, a filter, an isolate), and a
later sibling on the page can paint straight over it, whatever its
z-index says. */
const noop = () => () => {};
export function Portal({ children }: { children: React.ReactNode }) {
const client = useSyncExternalStore(noop, () => true, () => false);
return client ? createPortal(children, document.body) : null;
}
/* Layers, lowest first: dialogs, then toasts, then anything anchored to
a control (so a select inside a modal opens over the modal), then
tooltips over all of it. */
export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;
/* ── Anchored position ────────────────────────────────────────────── */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const PAD = 8;
interface AnchorOptions {
open: boolean;
side?: Side;
align?: Align;
/** Space between the anchor and the panel */
gap?: number;
/** Make the panel at least as wide as the anchor (selects, comboboxes) */
matchWidth?: boolean;
}
/* Places a fixed-position panel next to its anchor: on the side asked
for unless the other side has more room, then nudged back inside the
viewport. Follows the anchor while the page scrolls or resizes. The
position is written straight onto the element, so scrolling never
re-renders the component. */
export function useAnchoredPosition(
anchor: RefObject<HTMLElement | null>,
{ open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,
) {
const [placed, setPlaced] = useState<Side>(side);
const floating = useRef<HTMLElement | null>(null);
const update = useCallback(() => {
const a = anchor.current?.getBoundingClientRect();
const el = floating.current;
if (!a || !el) return;
if (matchWidth) el.style.minWidth = `${a.width}px`;
// offset sizes ignore the enter transform, so these are the settled size
const w = el.offsetWidth;
const h = el.offsetHeight;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };
const vertical = side === "top" || side === "bottom";
const need = (vertical ? h : w) + gap + PAD;
let s = side;
if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];
let top: number;
let left: number;
if (s === "top" || s === "bottom") {
top = s === "bottom" ? a.bottom + gap : a.top - gap - h;
left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;
} else {
left = s === "right" ? a.right + gap : a.left - gap - w;
top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;
}
left = Math.max(PAD, Math.min(left, vw - w - PAD));
top = Math.max(PAD, Math.min(top, vh - h - PAD));
el.style.top = `${Math.round(top)}px`;
el.style.left = `${Math.round(left)}px`;
el.dataset.side = s;
setPlaced(s);
}, [anchor, side, align, gap, matchWidth]);
/* Attach this as the panel's ref: it's placed before its first paint */
const setFloating = useCallback(
(el: HTMLElement | null) => {
floating.current = el;
if (el) update();
},
[update],
);
useEffect(() => {
if (!open) return;
let frame = 0;
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(update);
};
window.addEventListener("scroll", schedule, true);
window.addEventListener("resize", schedule);
// The panel can change size while open (a filtered list, a new month)
const ro = new ResizeObserver(schedule);
if (floating.current) ro.observe(floating.current);
if (anchor.current) ro.observe(anchor.current);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
ro.disconnect();
};
}, [open, update, anchor]);
return { setFloating, side: placed, floatingRef: floating, update };
}
/* Where the panel grows from, and where it slides in from, for each side */
export const ORIGIN: Record<Side, string> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
export const ENTER_FROM: Record<Side, string> = {
top: "translateY(4px)",
bottom: "translateY(-4px)",
left: "translateX(4px)",
right: "translateX(-4px)",
};
components/ui/FieldMessage.tsxtsx
"use client";
import React from "react";
/* The label above a field and the line under it, shared by every form
control so they read as one family. */
export function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
const className =
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
return htmlFor ? (
<label id={id} htmlFor={htmlFor} className={className}>
{children}
</label>
) : (
<span id={id} className={className}>
{children}
</span>
);
}
/* An error wins over a hint; either is announced through `id` */
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
if (error) {
return (
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
</svg>
{error}
</p>
);
}
if (hint) {
return (
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
{hint}
</p>
);
}
return null;
}
/* A small raised key that sits inside a field or a panel: steppers,
month arrows, clear buttons. It sinks flush while held. */
export function FieldKey({
label,
disabled,
onClick,
onPress,
onRelease,
focusable = true,
size = 28,
children,
}: {
label: string;
disabled?: boolean;
onClick?: () => void;
/** Pointer went down (for hold-to-repeat) */
onPress?: () => void;
onRelease?: () => void;
focusable?: boolean;
size?: number;
children: React.ReactNode;
}) {
const [pressed, setPressed] = React.useState(false);
const release = () => {
setPressed(false);
onRelease?.();
};
return (
<button
type="button"
aria-label={label}
tabIndex={focusable ? undefined : -1}
disabled={disabled}
onClick={onClick}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (!focusable) e.preventDefault(); // keep focus in the field
setPressed(true);
onPress?.();
}}
onPointerUp={release}
onPointerLeave={() => pressed && release()}
onPointerCancel={release}
className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
style={{
width: size,
height: size,
background: "var(--face)",
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
}}
>
<svg
aria-hidden
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
style={{
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
transition: "transform 150ms var(--ease-spring)",
}}
>
{children}
</svg>
</button>
);
}
components/ui/useFloatingPanel.tstsx
"use client";
import { CSSProperties, RefObject, useCallback, useEffect, useRef, useState } from "react";
import { Align, ENTER_FROM, LAYER, ORIGIN, Side, useAnchoredPosition } from "./Floating";
interface FloatingPanelOptions {
side?: Side;
align?: Align;
gap?: number;
matchWidth?: boolean;
/** Called after Escape closes the panel, to hand focus back */
onEscape?: () => void;
}
/* The open/close choreography every field-anchored panel shares (select,
date picker, colour picker, combobox): the panel is portalled to the
body and pinned next to its anchor, stays mounted while it folds away,
unfolds only once painted, and a click outside or Escape puts it back.
Render the panel inside <Portal> with ref={setFloating} and
style={panelStyle}. */
export function useFloatingPanel(anchor: RefObject<HTMLElement | null>, options: FloatingPanelOptions = {}) {
const { side = "bottom", align = "start", gap = 8, matchWidth = false, onEscape } = options;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [entered, setEntered] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const escape = useRef(onEscape);
useEffect(() => {
escape.current = onEscape;
});
const position = useAnchoredPosition(anchor, { open: mounted, side, align, gap, matchWidth });
const { floatingRef } = position;
const close = useCallback(() => {
setOpen(false);
setEntered(false);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMounted(false), 200);
}, []);
const show = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setOpen(true);
setMounted(true);
requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
}, []);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (anchor.current?.contains(t) || floatingRef.current?.contains(t)) return;
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
close();
escape.current?.();
}
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey, true);
};
}, [open, anchor, floatingRef, close]);
/* Unfolds out of the field: a clip reveals it from the anchor's side
while it fades and settles. The clip reaches past the panel so its
shadow is never cut off. */
const s = position.side;
const hidden: Record<Side, string> = {
bottom: "inset(-48px -48px 55% -48px)",
top: "inset(55% -48px -48px -48px)",
left: "inset(-48px -48px -48px 55%)",
right: "inset(-48px 55% -48px -48px)",
};
const panelStyle: CSSProperties = {
position: "fixed",
top: 0,
left: 0,
zIndex: LAYER.floating,
background: "var(--panel-bezel)",
boxShadow: "var(--overlay-shadow)",
transformOrigin: ORIGIN[s],
opacity: entered ? 1 : 0,
transform: entered ? "none" : `${ENTER_FROM[s]} scale(0.98)`,
clipPath: entered ? "inset(-48px -48px -48px -48px)" : hidden[s],
transition: entered
? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"
: "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",
};
return {
open,
mounted,
entered,
show,
close,
toggle: open ? close : show,
side: s,
setFloating: position.setFloating,
panelStyle,
};
}

usage

tsx
import { TimePicker } from "@blurrow/ui";
tsx
<TimePicker label="reminder" value={time} onChange={setTime} />

examples

12-hour, with limits

pickup

between 8 am and 6 pm

props

straight from the source, so they're always up to date.

propsts
interface TimePickerProps {
/** 24-hour "HH:mm", like "14:30" */
value?: string | null;
defaultValue?: string | null;
onChange?: (time: string | null) => void;
/** Minutes between choices in the minute column */
step?: number;
/** Show 1–12 with am / pm instead of 0–23 */
hour12?: boolean;
/** Earliest and latest times, as "HH:mm" */
min?: string;
max?: string;
label?: string;
hint?: string;
error?: string;
placeholder?: string;
clearable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
name?: string;
className?: string;
}