formsclient
date picker
a field that opens a calendar. arrows move by day and week, page up and down flip the month.
publish on
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 date-pickeror by hand: drop these files into components/ui and import from there. date picker leans on Floating.tsx, FieldMessage.tsx and useFloatingPanel.ts, so those come along too.
components/ui/DatePicker.tsxtsx
1"use client";2
3import React, { useId, useMemo, useRef, useState } from "react";4import { Portal } from "./Floating";5import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";6import { useFloatingPanel } from "./useFloatingPanel";7
8/* ── Date helpers ─────────────────────────────────────────────────── */9/* Everything works on local calendar days at midnight, so a date never10 drifts across a time zone boundary on its way through the picker. */11export const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());12const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);13const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);14const addMonths = (d: Date, n: number) => {15 const target = new Date(d.getFullYear(), d.getMonth() + n, 1);16 const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();17 return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));18};19const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();20export const sameDay = (a?: Date | null, b?: Date | null) =>21 !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();22export const toISODate = (d: Date) =>23 `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;24
25export interface DateRange {26 from: Date | null;27 to: Date | null;28}29
30/* ── Calendar ─────────────────────────────────────────────────────── */31interface CalendarProps {32 value?: Date | null;33 /** Shade a span of days instead of marking one (see DateRangePicker) */34 range?: DateRange;35 onChange?: (date: Date) => void;36 /** The day under the pointer or keyboard, for previewing a range */37 onDayHover?: (date: Date | null) => void;38 /** The month shown first; defaults to the selected date, or today */39 defaultMonth?: Date;40 /** How many months sit side by side */41 months?: 1 | 2;42 min?: Date;43 max?: Date;44 /** Grey out and skip particular days, like weekends */45 isDateDisabled?: (date: Date) => boolean;46 /** 0 is Sunday, 1 is Monday */47 weekStartsOn?: 0 | 1;48 locale?: string;49 /** Move focus onto the grid as soon as it mounts */50 autoFocus?: boolean;51 className?: string;52}53
54/* A month of days on the panel's face. The chosen day is a raised key in55 the inverse material, today wears a small dot, and the grid follows56 the ARIA date-grid pattern: arrows move by day and week, page keys by57 month (shift for a year), home and end to the edges of the week. */58export function Calendar({59 value,60 range,61 onChange,62 onDayHover,63 defaultMonth,64 months = 1,65 min,66 max,67 isDateDisabled,68 weekStartsOn = 0,69 locale = "en-US",70 autoFocus = false,71 className = "",72}: CalendarProps) {73 const uid = useId();74 const [today] = useState(() => startOfDay(new Date()));75 const [focused, setFocused] = useState<Date>(() => startOfDay(value ?? range?.from ?? defaultMonth ?? today));76 const [view, setView] = useState<Date>(() => startOfMonth(focused));77 const [direction, setDirection] = useState<"left" | "right">("right");78 // Set by keyboard moves, spent by the next day button that mounts focused79 const pendingFocus = useRef(autoFocus);80
81 // A value set from outside brings its month into view82 const anchor = value ?? range?.from ?? null;83 const [seen, setSeen] = useState(anchor?.getTime());84 if (anchor?.getTime() !== seen) {85 setSeen(anchor?.getTime());86 if (anchor) {87 const d = startOfDay(anchor);88 setFocused(d);89 const shown = monthIndex(d) - monthIndex(view);90 if (shown < 0 || shown >= months) setView(startOfMonth(d));91 }92 }93
94 const minDay = min && startOfDay(min);95 const maxDay = max && startOfDay(max);96 const disabled = (d: Date) =>97 (minDay && d < minDay) || (maxDay && d > maxDay) || (isDateDisabled?.(d) ?? false);98
99 const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);100 const dayLabel = useMemo(101 () => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),102 [locale],103 );104 const weekdays = useMemo(() => {105 const short = new Intl.DateTimeFormat(locale, { weekday: "short" });106 const long = new Intl.DateTimeFormat(locale, { weekday: "long" });107 // 2023-01-01 was a Sunday108 return Array.from({ length: 7 }, (_, i) => {109 const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));110 return { short: short.format(d).slice(0, 2), long: long.format(d) };111 });112 }, [locale, weekStartsOn]);113
114 /* Moves the roving focus, scrolling the view when the day is off it */115 const move = (next: Date, viaKeyboard: boolean) => {116 pendingFocus.current = viaKeyboard;117 const offset = monthIndex(next) - monthIndex(view);118 if (offset < 0) {119 setDirection("left");120 setView(startOfMonth(next));121 } else if (offset >= months) {122 setDirection("right");123 setView(startOfMonth(addMonths(next, -(months - 1))));124 }125 setFocused(next);126 if (viaKeyboard) onDayHover?.(next);127 };128
129 const flip = (n: number) => {130 pendingFocus.current = false;131 setDirection(n > 0 ? "right" : "left");132 setView(addMonths(view, n));133 setFocused(addMonths(focused, n));134 };135
136 const onKeyDown = (e: React.KeyboardEvent) => {137 const dow = (focused.getDay() - weekStartsOn + 7) % 7;138 const map: Record<string, () => Date> = {139 ArrowLeft: () => addDays(focused, -1),140 ArrowRight: () => addDays(focused, 1),141 ArrowUp: () => addDays(focused, -7),142 ArrowDown: () => addDays(focused, 7),143 PageUp: () => addMonths(focused, e.shiftKey ? -12 : -1),144 PageDown: () => addMonths(focused, e.shiftKey ? 12 : 1),145 Home: () => addDays(focused, -dow),146 End: () => addDays(focused, 6 - dow),147 };148 if (map[e.key]) {149 e.preventDefault();150 move(map[e.key](), true);151 }152 };153
154 /* The focused day takes focus after it renders, but only when the155 keyboard moved it: a click shouldn't yank focus around. */156 const focusRef = (el: HTMLButtonElement | null) => {157 if (!el || !pendingFocus.current) return;158 pendingFocus.current = false;159 el.focus({ preventScroll: true });160 };161
162 const prevDisabled = !!minDay && addDays(view, -1) < minDay;163 const nextDisabled = !!maxDay && addMonths(view, months) > maxDay;164
165 const from = range?.from ? startOfDay(range.from) : null;166 const to = range?.to ? startOfDay(range.to) : null;167
168 return (169 <div170 className={["flex flex-wrap justify-center gap-x-6 gap-y-4 select-none", className].join(" ")}171 onKeyDown={onKeyDown}172 onMouseLeave={() => onDayHover?.(null)}173 >174 {Array.from({ length: months }, (_, m) => {175 const month = addMonths(view, m);176 const first = startOfMonth(month);177 const gridStart = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));178 // Always six weeks, so the panel keeps its height from month to month179 const weeks = Array.from({ length: 6 }, (_, w) =>180 Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)),181 );182 const labelId = `${uid}-month-${m}`;183 return (184 <div key={m} className="w-[266px]">185 <div className="mb-2 flex h-7 items-center justify-between gap-2">186 {m === 0 ? (187 <FieldKey label="previous month" disabled={prevDisabled} onClick={() => flip(-1)}>188 <path d="M15 19l-7-7 7-7" />189 </FieldKey>190 ) : (191 <span className="w-7" />192 )}193 <span id={labelId} aria-live="polite" className="text-[13.5px] font-semibold text-ink tracking-[-0.01em]">194 {monthFmt.format(month)}195 </span>196 {m === months - 1 ? (197 <FieldKey label="next month" disabled={nextDisabled} onClick={() => flip(1)}>198 <path d="M9 5l7 7-7 7" />199 </FieldKey>200 ) : (201 <span className="w-7" />202 )}203 </div>204
205 <div role="grid" aria-labelledby={labelId}>206 <div role="row" className="grid grid-cols-7">207 {weekdays.map((w) => (208 <span209 key={w.long}210 role="columnheader"211 aria-label={w.long}212 className="grid h-7 place-items-center text-[11px] font-medium text-ink-faint"213 >214 {w.short}215 </span>216 ))}217 </div>218 <div219 key={monthIndex(month)}220 className={direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left"}221 >222 {weeks.map((week, wi) => (223 <div role="row" key={wi} className="grid grid-cols-7">224 {week.map((d) => {225 const inMonth = d.getMonth() === month.getMonth();226 // With two months the spill-over days would show twice227 if (!inMonth && months > 1) {228 return <div role="gridcell" key={d.getTime()} className="h-[38px]" />;229 }230 const isEndpoint = range ? sameDay(d, from) || sameDay(d, to) : sameDay(d, value);231 const inBand = !!(from && to && d > from && d < to);232 const bandLeft = !!(from && to && sameDay(d, to) && !sameDay(from, to));233 const bandRight = !!(from && to && sameDay(d, from) && !sameDay(from, to));234 const isToday = sameDay(d, today);235 const isOff = disabled(d);236 const isFocus = sameDay(d, focused) && inMonth;237 return (238 <div239 role="gridcell"240 key={d.getTime()}241 aria-selected={isEndpoint || inBand}242 className="relative grid h-[38px] place-items-center"243 >244 {/* The span of a range, cut into the face like a well */}245 {(inBand || bandLeft || bandRight) && (246 <span247 aria-hidden248 className="absolute inset-y-[3px]"249 style={{250 left: bandLeft || inBand ? 0 : "50%",251 right: bandRight || inBand ? 0 : "50%",252 background: "var(--well)",253 boxShadow: "var(--well-shadow)",254 }}255 />256 )}257 <button258 type="button"259 ref={isFocus ? focusRef : undefined}260 tabIndex={isFocus ? 0 : -1}261 disabled={isOff}262 aria-label={dayLabel.format(d)}263 aria-current={isToday ? "date" : undefined}264 onClick={() => {265 move(d, false);266 if (!isOff) onChange?.(d);267 }}268 onMouseEnter={() => onDayHover?.(d)}269 className={[270 "relative grid h-9 w-9 place-items-center rounded-[10px] text-[13px] tabular-nums",271 "transition-[background-color,color,box-shadow] duration-150",272 "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",273 "disabled:cursor-not-allowed disabled:text-ink-ghost disabled:line-through",274 isEndpoint275 ? "font-semibold text-ink-inverse"276 : [277 "cursor-pointer hover:bg-surface-active",278 !inMonth ? "text-ink-ghost" : isToday ? "font-semibold text-ink-strong" : "text-ink-soft",279 ].join(" "),280 ].join(" ")}281 style={282 isEndpoint283 ? {284 background: "var(--face-inverse)",285 boxShadow: "var(--face-inverse-shadow)",286 textShadow: "var(--emboss-inverse)",287 }288 : undefined289 }290 >291 {d.getDate()}292 {isToday && (293 <span294 aria-hidden295 className={[296 "absolute bottom-[5px] left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full",297 isEndpoint ? "bg-ink-inverse" : "bg-ink",298 ].join(" ")}299 />300 )}301 </button>302 </div>303 );304 })}305 </div>306 ))}307 </div>308 </div>309 </div>310 );311 })}312 </div>313 );314}315
316/* ── Pieces both pickers share ────────────────────────────────────── */317export const CalendarIcon = ({ className = "" }: { className?: string }) => (318 <svg aria-hidden width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" className={className}>319 <rect x="3" y="5" width="18" height="16" rx="2.5" />320 <path strokeLinecap="round" d="M3 10h18M8 3v4M16 3v4" />321 </svg>322);323
324export function ClearKey({ label, onClear }: { label: string; onClear: () => void }) {325 return (326 <button327 type="button"328 aria-label={label}329 onClick={onClear}330 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"331 >332 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">333 <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />334 </svg>335 </button>336 );337}338
339/* ── DatePicker ───────────────────────────────────────────────────── */340interface DatePickerProps {341 value?: Date | null;342 defaultValue?: Date | null;343 onChange?: (date: Date | null) => void;344 label?: string;345 hint?: string;346 error?: string;347 placeholder?: string;348 min?: Date;349 max?: Date;350 isDateDisabled?: (date: Date) => boolean;351 weekStartsOn?: 0 | 1;352 locale?: string;353 /** How the chosen date reads in the field */354 format?: Intl.DateTimeFormatOptions;355 /** Submit the date with a form, as yyyy-mm-dd */356 name?: string;357 /** Show a small key in the field that empties it */358 clearable?: boolean;359 disabled?: boolean;360 fullWidth?: boolean;361 className?: string;362}363
364const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };365
366/* The closed picker is a sunken field like Select; the calendar opens367 under it on a floating panel. Picking a day closes it and hands368 focus back to the field. */369export function DatePicker({370 value,371 defaultValue = null,372 onChange,373 label,374 hint,375 error,376 placeholder = "pick a date",377 min,378 max,379 isDateDisabled,380 weekStartsOn,381 locale = "en-US",382 format = DEFAULT_FORMAT,383 name,384 clearable = false,385 disabled = false,386 fullWidth = false,387 className = "",388}: DatePickerProps) {389 const uid = useId();390 const ref = useRef<HTMLDivElement>(null);391 const buttonRef = useRef<HTMLButtonElement>(null);392 const [internal, setInternal] = useState<Date | null>(defaultValue);393 const selected = value !== undefined ? value : internal;394 const { open, mounted, toggle, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {395 onEscape: () => buttonRef.current?.focus(),396 });397 const messageId = error || hint ? `${uid}-message` : undefined;398 const text = selected ? new Intl.DateTimeFormat(locale, format).format(selected) : null;399
400 const set = (d: Date | null) => {401 setInternal(d);402 onChange?.(d);403 };404
405 return (406 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>407 {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}408 <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-56", className].join(" ")}>409 {/* The field's error styling keys off aria-invalid, as Input's does */}410 {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}411 <button412 ref={buttonRef}413 type="button"414 disabled={disabled}415 onClick={toggle}416 onKeyDown={(e) => {417 if (!open && e.key === "ArrowDown") {418 e.preventDefault();419 show();420 }421 }}422 aria-haspopup="dialog"423 aria-expanded={open}424 aria-controls={open ? `${uid}-panel` : undefined}425 aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}426 aria-invalid={error ? true : undefined}427 aria-describedby={messageId}428 data-open={open || undefined}429 className={[430 "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]",431 clearable && selected ? "pr-10" : "pr-4",432 "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",433 ].join(" ")}434 >435 <CalendarIcon436 className={[437 "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",438 open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",439 ].join(" ")}440 />441 <span442 id={`${uid}-value`}443 className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}444 >445 {text ?? placeholder}446 </span>447 </button>448
449 {clearable && selected && !disabled && (450 <ClearKey451 label="clear date"452 onClear={() => {453 set(null);454 buttonRef.current?.focus();455 }}456 />457 )}458
459 {name && <input type="hidden" name={name} value={selected ? toISODate(selected) : ""} />}460 </div>461
462 {mounted && (463 <Portal>464 <div465 ref={setFloating}466 id={`${uid}-panel`}467 role="dialog"468 aria-label={label ? `choose ${label}` : "choose a date"}469 className="rounded-[16px] p-1.5"470 style={panelStyle}471 >472 <div className="rounded-[11px] bg-panel-face p-2.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>473 <Calendar474 value={selected}475 onChange={(d) => {476 set(d);477 close();478 buttonRef.current?.focus();479 }}480 min={min}481 max={max}482 isDateDisabled={isDateDisabled}483 weekStartsOn={weekStartsOn}484 locale={locale}485 autoFocus486 />487 </div>488 </div>489 </Portal>490 )}491 <FieldMessage id={messageId} error={error} hint={hint} />492 </div>493 );494}components/ui/Floating.tsxtsx
1"use client";2
3import React, { RefObject, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5
6/* ── Portal ───────────────────────────────────────────────────────── */7/* Anything that floats over the page is rendered at the end of <body>.8 Left where it was declared, a panel lives inside whatever stacking9 context its parents made (a transform, a filter, an isolate), and a10 later sibling on the page can paint straight over it, whatever its11 z-index says. */12const noop = () => () => {};13
14export function Portal({ children }: { children: React.ReactNode }) {15 const client = useSyncExternalStore(noop, () => true, () => false);16 return client ? createPortal(children, document.body) : null;17}18
19/* Layers, lowest first: dialogs, then toasts, then anything anchored to20 a control (so a select inside a modal opens over the modal), then21 tooltips over all of it. */22export const LAYER = { dialog: 90, toast: 95, floating: 100, tooltip: 110 } as const;23
24/* ── Anchored position ────────────────────────────────────────────── */25export type Side = "top" | "bottom" | "left" | "right";26export type Align = "start" | "center" | "end";27
28const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };29const PAD = 8;30
31interface AnchorOptions {32 open: boolean;33 side?: Side;34 align?: Align;35 /** Space between the anchor and the panel */36 gap?: number;37 /** Make the panel at least as wide as the anchor (selects, comboboxes) */38 matchWidth?: boolean;39}40
41/* Places a fixed-position panel next to its anchor: on the side asked42 for unless the other side has more room, then nudged back inside the43 viewport. Follows the anchor while the page scrolls or resizes. The44 position is written straight onto the element, so scrolling never45 re-renders the component. */46export function useAnchoredPosition(47 anchor: RefObject<HTMLElement | null>,48 { open, side = "bottom", align = "start", gap = 8, matchWidth = false }: AnchorOptions,49) {50 const [placed, setPlaced] = useState<Side>(side);51 const floating = useRef<HTMLElement | null>(null);52
53 const update = useCallback(() => {54 const a = anchor.current?.getBoundingClientRect();55 const el = floating.current;56 if (!a || !el) return;57 if (matchWidth) el.style.minWidth = `${a.width}px`;58
59 // offset sizes ignore the enter transform, so these are the settled size60 const w = el.offsetWidth;61 const h = el.offsetHeight;62 const vw = document.documentElement.clientWidth;63 const vh = window.innerHeight;64 const room: Record<Side, number> = { top: a.top, bottom: vh - a.bottom, left: a.left, right: vw - a.right };65 const vertical = side === "top" || side === "bottom";66 const need = (vertical ? h : w) + gap + PAD;67
68 let s = side;69 if (room[s] < need && room[OPPOSITE[s]] > room[s]) s = OPPOSITE[s];70
71 let top: number;72 let left: number;73 if (s === "top" || s === "bottom") {74 top = s === "bottom" ? a.bottom + gap : a.top - gap - h;75 left = align === "start" ? a.left : align === "end" ? a.right - w : a.left + (a.width - w) / 2;76 } else {77 left = s === "right" ? a.right + gap : a.left - gap - w;78 top = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + (a.height - h) / 2;79 }80 left = Math.max(PAD, Math.min(left, vw - w - PAD));81 top = Math.max(PAD, Math.min(top, vh - h - PAD));82
83 el.style.top = `${Math.round(top)}px`;84 el.style.left = `${Math.round(left)}px`;85 el.dataset.side = s;86 setPlaced(s);87 }, [anchor, side, align, gap, matchWidth]);88
89 /* Attach this as the panel's ref: it's placed before its first paint */90 const setFloating = useCallback(91 (el: HTMLElement | null) => {92 floating.current = el;93 if (el) update();94 },95 [update],96 );97
98 useEffect(() => {99 if (!open) return;100 let frame = 0;101 const schedule = () => {102 cancelAnimationFrame(frame);103 frame = requestAnimationFrame(update);104 };105 window.addEventListener("scroll", schedule, true);106 window.addEventListener("resize", schedule);107 // The panel can change size while open (a filtered list, a new month)108 const ro = new ResizeObserver(schedule);109 if (floating.current) ro.observe(floating.current);110 if (anchor.current) ro.observe(anchor.current);111 return () => {112 cancelAnimationFrame(frame);113 window.removeEventListener("scroll", schedule, true);114 window.removeEventListener("resize", schedule);115 ro.disconnect();116 };117 }, [open, update, anchor]);118
119 return { setFloating, side: placed, floatingRef: floating, update };120}121
122/* Where the panel grows from, and where it slides in from, for each side */123export const ORIGIN: Record<Side, string> = {124 top: "bottom",125 bottom: "top",126 left: "right",127 right: "left",128};129export const ENTER_FROM: Record<Side, string> = {130 top: "translateY(4px)",131 bottom: "translateY(-4px)",132 left: "translateX(4px)",133 right: "translateX(-4px)",134};components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 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"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}components/ui/useFloatingPanel.tstsx
1"use client";2
3import { CSSProperties, RefObject, useCallback, useEffect, useRef, useState } from "react";4import { Align, ENTER_FROM, LAYER, ORIGIN, Side, useAnchoredPosition } from "./Floating";5
6interface FloatingPanelOptions {7 side?: Side;8 align?: Align;9 gap?: number;10 matchWidth?: boolean;11 /** Called after Escape closes the panel, to hand focus back */12 onEscape?: () => void;13}14
15/* The open/close choreography every field-anchored panel shares (select,16 date picker, colour picker, combobox): the panel is portalled to the17 body and pinned next to its anchor, stays mounted while it folds away,18 unfolds only once painted, and a click outside or Escape puts it back.19 Render the panel inside <Portal> with ref={setFloating} and20 style={panelStyle}. */21export function useFloatingPanel(anchor: RefObject<HTMLElement | null>, options: FloatingPanelOptions = {}) {22 const { side = "bottom", align = "start", gap = 8, matchWidth = false, onEscape } = options;23 const [open, setOpen] = useState(false);24 const [mounted, setMounted] = useState(false);25 const [entered, setEntered] = useState(false);26 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);27 const escape = useRef(onEscape);28 useEffect(() => {29 escape.current = onEscape;30 });31
32 const position = useAnchoredPosition(anchor, { open: mounted, side, align, gap, matchWidth });33 const { floatingRef } = position;34
35 const close = useCallback(() => {36 setOpen(false);37 setEntered(false);38 if (timer.current) clearTimeout(timer.current);39 timer.current = setTimeout(() => setMounted(false), 200);40 }, []);41
42 const show = useCallback(() => {43 if (timer.current) clearTimeout(timer.current);44 setOpen(true);45 setMounted(true);46 requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));47 }, []);48
49 useEffect(() => () => {50 if (timer.current) clearTimeout(timer.current);51 }, []);52
53 useEffect(() => {54 if (!open) return;55 const onDown = (e: PointerEvent) => {56 const t = e.target as Node;57 if (anchor.current?.contains(t) || floatingRef.current?.contains(t)) return;58 close();59 };60 const onKey = (e: KeyboardEvent) => {61 if (e.key === "Escape") {62 e.stopPropagation();63 close();64 escape.current?.();65 }66 };67 document.addEventListener("pointerdown", onDown);68 document.addEventListener("keydown", onKey, true);69 return () => {70 document.removeEventListener("pointerdown", onDown);71 document.removeEventListener("keydown", onKey, true);72 };73 }, [open, anchor, floatingRef, close]);74
75 /* Unfolds out of the field: a clip reveals it from the anchor's side76 while it fades and settles. The clip reaches past the panel so its77 shadow is never cut off. */78 const s = position.side;79 const hidden: Record<Side, string> = {80 bottom: "inset(-48px -48px 55% -48px)",81 top: "inset(55% -48px -48px -48px)",82 left: "inset(-48px -48px -48px 55%)",83 right: "inset(-48px 55% -48px -48px)",84 };85 const panelStyle: CSSProperties = {86 position: "fixed",87 top: 0,88 left: 0,89 zIndex: LAYER.floating,90 background: "var(--panel-bezel)",91 boxShadow: "var(--overlay-shadow)",92 transformOrigin: ORIGIN[s],93 opacity: entered ? 1 : 0,94 transform: entered ? "none" : `${ENTER_FROM[s]} scale(0.98)`,95 clipPath: entered ? "inset(-48px -48px -48px -48px)" : hidden[s],96 transition: entered97 ? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"98 : "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",99 };100
101 return {102 open,103 mounted,104 entered,105 show,106 close,107 toggle: open ? close : show,108 side: s,109 setFloating: position.setFloating,110 panelStyle,111 };112}usage
tsx
import { Calendar, CalendarIcon, ClearKey, DatePicker } from "@blurrow/ui";tsx
<DatePicker label="publish on" value={date} onChange={setDate} />examples
limits
min, max and isdatedisabled keep people to the days that work.
book a call
weekdays in the next six weeks
calendar on its own
the grid is exported as Calendar, for when the month should always be showing.
November 2026
SuMoTuWeThFrSa
props
straight from the source, so they're always up to date.
propsts
interface DateRange { from: Date | null; to: Date | null;}
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;}
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;}