Date pickerNew
One date, typed or picked: the field reads "tomorrow", "next fri" or "4 nov", and the calendar slides by month while one mark glides between days.
Used forDue datesBookingsFiltersProfiles
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/date-picker.jsonTemplates
Need the whole page?
43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.
Where it’s used
Due dates
Set a task for "next fri" without opening anything.
Bookings
A delivery or appointment day with limits.
Filters
A single "since" date above a report.
Profiles
Start dates and birthdays.
What it handles for you
Calendars are slow for dates people already know, and typed fields are strict about format. This accepts both.
- 01
Natural typing
today, tomorrow, weekdays, "in 3 days/weeks/months", "4 nov", "nov 4", 4/11/2026, ISO.
- 02
Moving mark
One selection circle thrown between days (420ms).
- 03
Full keyboard
Arrows by day/week, Page Up/Down by month, Home/End, Enter.
- 04
No time-zone bugs
Plain YYYY-MM-DD strings in and out.
Overview
Date fields usually make you click through a calendar or type an exact format. This one reads what people write ("tomorrow", "in 3 days", "next fri", "4 nov", "2026-11-04") and settles it into "Wed 4 Nov 2026"; anything it can't read shakes and says so. The calendar rises off the field, months slide in from the side you moved toward while the month name morphs, and the chosen day's mark glides from day to day. Values are plain "YYYY-MM-DD" strings, so time zones never shift a day.
States
- typed
- Enter or leaving the field settles the text into a formatted date.
- unreadable
- Red line, a hint of what works, one shake.
- calendar
- Rises off the field; months slide 24px out of a blur (360ms); the month name morphs.
- pick
- The mark glides to the day; the calendar closes and focus returns to the field.
Usage
import { DatePicker } from "@/components/ui/date-picker";<DatePicker label="Due" value={due} onValueChange={setDue} min="2026-10-20" />Works with
Props
| Prop | Type | Description |
|---|---|---|
| value / defaultValue | string | null | "YYYY-MM-DD". |
| onValueChange | (value: string | null) => void | Called with the new day (null when cleared). |
| min / max | string | Allowed range. |
| weekStartsOn | 0 | 1default 1 | Sunday or Monday. |
| locale | stringdefault "en-GB" | Formatting. |
| label / hint / error / placeholder | ReactNode | As in Input. |
Accessibility and motion
- parseDate(input, today) is exported for your own fields.
- Alt+↓ opens the calendar from the field; the grid uses role="grid" with labelled days.
- Reduced motion: months and the mark change in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Text morph, ui-interaction-motion.
Source
"use client";import * as React from "react";import { createPortal } from "react-dom";import { Field, fieldSurface, useFieldIds, useShake } from "./field";import { enterStyle, themeVars, useDismiss, useFloating, usePresence } from "./floating";import { TextMorph } from "./text-morph";import { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * DATE PICKER: one date, typed or picked * * type the field reads what people write: "tomorrow", * "next fri", "in 3 days", "4 nov", "2026-11-04"; Enter * or leaving the field settles it into "Wed 4 Nov 2026", * and anything it can't read shakes and says so * pick the calendar rises off the field; months slide in from * the side you moved toward while the month name morphs; * the chosen day's mark glides from day to day * keys arrows move a day or a week, Page Up/Down a month, * Home/End the week's ends, Enter picks, Escape closes * * Values are plain "YYYY-MM-DD" strings: no time zones to trip on. * ───────────────────────────────────────────────────────── */export interface DatePickerProps { value?: string | null; defaultValue?: string | null; onValueChange?: (value: string | null) => void; label?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; placeholder?: string; /** Earliest and latest allowed days, "YYYY-MM-DD". */ min?: string; max?: string; /** 1 = Monday (default), 0 = Sunday. */ weekStartsOn?: 0 | 1; locale?: string; id?: string; disabled?: boolean; className?: string;}// ── Plain-date helpers (UTC under the hood so no zone shifts a day) ──const toISO = (d: Date) => d.toISOString().slice(0, 10);const fromISO = (s: string) => new Date(`${s}T00:00:00Z`);const addDays = (s: string, n: number) => { const d = fromISO(s); d.setUTCDate(d.getUTCDate() + n); return toISO(d);};const addMonths = (s: string, n: number) => { const d = fromISO(s); const day = d.getUTCDate(); d.setUTCDate(1); d.setUTCMonth(d.getUTCMonth() + n); const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate(); d.setUTCDate(Math.min(day, last)); return toISO(d);};const monthOf = (s: string) => s.slice(0, 7);const localToday = () => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`;};const subscribeDay = (cb: () => void) => { const t = window.setInterval(cb, 60_000); return () => window.clearInterval(t);};const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];/** Reads a typed date relative to `today`; null if it can't. */export function parseDate(input: string, today: string): string | null { const t = input.trim().toLowerCase().replace(/\s+/g, " "); if (!t) return null; if (t === "today" || t === "now") return today; if (t === "tomorrow" || t === "tmr") return addDays(today, 1); if (t === "yesterday") return addDays(today, -1); let m = t.match(/^(?:in )?\+?(\d+) ?(d|day|days|w|wk|week|weeks|m|mo|month|months)(?: from now)?$/); if (m) { const n = Number(m[1]); return m[2].startsWith("m") ? addMonths(today, n) : addDays(today, m[2].startsWith("w") ? n * 7 : n); } m = t.match(/^(next |this )?(sun|mon|tue|wed|thu|fri|sat)[a-z]*$/); if (m) { const want = DAYS.indexOf(m[2]); const now = fromISO(today).getUTCDay(); let diff = (want - now + 7) % 7; if (diff === 0 || m[1] === "next ") diff = diff === 0 ? 7 : diff; return addDays(today, diff); } m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (m) return valid(+m[1], +m[2], +m[3]); // "4 nov", "4 november 2027", "nov 4", "november 4, 2027" m = t.match(/^(\d{1,2})(?:st|nd|rd|th)? ([a-z]{3})[a-z]*,? ?(\d{4})?$/) ?? null; if (m) return withYear(+m[1], MONTHS.indexOf(m[2]) + 1, m[3], today); m = t.match(/^([a-z]{3})[a-z]* (\d{1,2})(?:st|nd|rd|th)?,? ?(\d{4})?$/); if (m) return withYear(+m[2], MONTHS.indexOf(m[1]) + 1, m[3], today); // 4/11/2026 reads day first m = t.match(/^(\d{1,2})[/.](\d{1,2})(?:[/.](\d{2,4}))?$/); if (m) return withYear(+m[1], +m[2], m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : undefined, today); return null;}function valid(y: number, mo: number, d: number) { if (mo < 1 || mo > 12 || d < 1) return null; const date = new Date(Date.UTC(y, mo - 1, d)); return date.getUTCMonth() === mo - 1 ? toISO(date) : null;}function withYear(d: number, mo: number, y: string | undefined, today: string) { if (mo < 1) return null; if (y) return valid(+y, mo, d); const thisYear = valid(+today.slice(0, 4), mo, d); // No year: the next time that date comes round. return thisYear && thisYear < today ? valid(+today.slice(0, 4) + 1, mo, d) : thisYear;}function Arrow({ back }: { back?: boolean }) { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5"> <path d={back ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}export function DatePicker({ value, defaultValue = null, onValueChange, label, hint, error, placeholder = "Pick a date or type one", min, max, weekStartsOn = 1, locale = "en-GB", id, disabled, className = "",}: DatePickerProps) { const reduced = useInteractionReducedMotion(); const ids = useFieldIds(id); const today = React.useSyncExternalStore(subscribeDay, localToday, () => null); const [own, setOwn] = React.useState<string | null>(defaultValue); const current = value !== undefined ? value : own; const [draft, setDraft] = React.useState<string | null>(null); const [badInput, setBadInput] = React.useState(false); const [open, setOpen] = React.useState(false); const [view, setView] = React.useState(() => monthOf(current ?? defaultValue ?? "2026-01-01")); const [focusDay, setFocusDay] = React.useState<string | null>(null); const [dir, setDir] = React.useState(0); const anchor = React.useRef<HTMLDivElement>(null); const surface = React.useRef<HTMLDivElement>(null); const grid = React.useRef<HTMLDivElement>(null); const { mounted, shown } = usePresence(open, 180); const place = useFloating({ open: mounted, anchor, floating: surface, side: "bottom", align: "start", offset: 6 }); const [vars, setVars] = React.useState<React.CSSProperties>({}); const shakeRef = useShake<HTMLDivElement>(badInput || !!error); const [mark, setMark] = React.useState<{ x: number; y: number } | null>(null); const [markReady, setMarkReady] = React.useState(false); const fmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }), [locale]); const monthFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }), [locale]); const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale]); const headFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }), [locale]); const allowed = (d: string) => (!min || d >= min) && (!max || d <= max); const commit = (next: string | null) => { if (value === undefined) setOwn(next); onValueChange?.(next); }; const settle = () => { if (draft === null) return; const text = draft.trim(); if (!text) { setDraft(null); setBadInput(false); return commit(null); } const parsed = parseDate(text, today ?? localToday()); if (!parsed || !allowed(parsed)) return setBadInput(true); setDraft(null); setBadInput(false); setView(monthOf(parsed)); commit(parsed); }; const openCalendar = () => { const start = current ?? today ?? localToday(); setView(monthOf(start)); setFocusDay(start); setDir(0); setOpen(true); }; const close = (refocus: boolean) => { setOpen(false); if (refocus) document.getElementById(ids.id)?.focus(); }; useDismiss(open, (reason) => close(reason === "escape"), [anchor, surface]); React.useLayoutEffect(() => { if (mounted) setVars(themeVars(anchor.current)); }, [mounted]); // Days of the shown month, padded to whole weeks. const days = React.useMemo(() => { const first = fromISO(`${view}-01`); const lead = (first.getUTCDay() - weekStartsOn + 7) % 7; const start = addDays(`${view}-01`, -lead); const count = Math.ceil((lead + new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()) / 7) * 7; return Array.from({ length: count }, (_, i) => addDays(start, i)); }, [view, weekStartsOn]); const goMonth = (n: number) => { setDir(n); setView((v) => monthOf(addMonths(`${v}-01`, n))); setFocusDay((f) => (f ? addMonths(f, n) : f)); }; // The chosen day's mark glides within a month; a new month slides in from its side. React.useLayoutEffect(() => { const g = grid.current; if (!g) return; const measure = () => { const el = current ? g.querySelector<HTMLElement>(`[data-day="${current}"]`) : null; setMark((p) => (el ? (p && p.x === el.offsetLeft && p.y === el.offsetTop ? p : { x: el.offsetLeft, y: el.offsetTop }) : null)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(g); return () => ro.disconnect(); }, [current, view, mounted]); React.useEffect(() => { if (!mark || markReady) return; const f = requestAnimationFrame(() => setMarkReady(true)); return () => cancelAnimationFrame(f); }, [mark, markReady]); React.useLayoutEffect(() => { if (!dir || reduced) return; grid.current?.animate( [ { opacity: 0, transform: `translateX(${dir * 24}px)`, filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }, ], { duration: 360, easing: EASE_OUT }, ); }, [view, dir, reduced]); // Keep keyboard focus on the focused day once the calendar is up. React.useEffect(() => { if (!open || !shown || !focusDay) return; grid.current?.querySelector<HTMLElement>(`[data-day="${focusDay}"]`)?.focus({ preventScroll: true }); }, [open, shown, focusDay, view]); const onGridKey = (e: React.KeyboardEvent) => { if (!focusDay) return; const moves: Record<string, () => string> = { ArrowLeft: () => addDays(focusDay, -1), ArrowRight: () => addDays(focusDay, 1), ArrowUp: () => addDays(focusDay, -7), ArrowDown: () => addDays(focusDay, 7), PageUp: () => addMonths(focusDay, -1), PageDown: () => addMonths(focusDay, 1), Home: () => addDays(focusDay, -((fromISO(focusDay).getUTCDay() - weekStartsOn + 7) % 7)), End: () => addDays(focusDay, 6 - ((fromISO(focusDay).getUTCDay() - weekStartsOn + 7) % 7)), }; if (e.key in moves) { e.preventDefault(); const next = moves[e.key](); if (monthOf(next) !== view) { setDir(next > focusDay ? 1 : -1); setView(monthOf(next)); } setFocusDay(next); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (allowed(focusDay)) { commit(focusDay); close(true); } } }; const shownText = draft ?? (current ? fmt.format(fromISO(current)) : ""); const describedBy = [hint && !error && !badInput ? ids.hint : null, error || badInput ? ids.error : null].filter(Boolean).join(" ") || undefined; return ( <Field id={ids.id} label={label} hint={hint} error={error ?? (badInput ? "Couldn't read that date. Try “tomorrow” or “4 Nov”." : undefined)} className={className}> <div ref={anchor}> <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error || badInput, disabled })} h-9 text-[16px] sm:text-[13.5px]`}> <input id={ids.id} value={shownText} placeholder={placeholder} disabled={disabled} aria-invalid={error || badInput ? true : undefined} aria-describedby={describedBy} onChange={(e) => { setDraft(e.target.value); setBadInput(false); }} onBlur={(e) => { if (!surface.current?.contains(e.relatedTarget as Node)) settle(); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); settle(); } else if (e.key === "ArrowDown" && e.altKey) { e.preventDefault(); openCalendar(); } }} className="h-full min-w-0 flex-1 bg-transparent pl-4 pr-1 outline-none placeholder:text-muted-foreground/70" /> <button type="button" aria-label="Choose date" aria-haspopup="dialog" aria-expanded={open} disabled={disabled} onClick={() => (open ? close(false) : openCalendar())} className={`mr-1 flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`} > <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5"> <rect x="2.5" y="3.5" width="11" height="10" rx="2" stroke="currentColor" strokeWidth="1.4" /> <path d="M2.5 6.5h11M5.5 2v3M10.5 2v3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> </svg> </button> </div> </div> {mounted && createPortal( <div ref={surface} role="dialog" aria-label="Choose date" className="fixed left-0 top-0 z-[55] w-[284px] rounded-[18px] bg-popover p-3 text-popover-foreground shadow-[0_0_0_1px_var(--border)]" style={{ ...vars, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }} > <div className="mb-2 flex items-center justify-between pl-2"> <span className="text-[13.5px] font-medium text-foreground" aria-live="polite"> <TextMorph>{monthFmt.format(fromISO(`${view}-01`))}</TextMorph> </span> <div className="flex"> {[-1, 1].map((n) => ( <button key={n} type="button" aria-label={n < 0 ? "Previous month" : "Next month"} onClick={() => goMonth(n)} className={`flex size-7 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`} > <Arrow back={n < 0} /> </button> ))} </div> </div> <div className="grid grid-cols-7 pb-1 text-center text-[11px] text-muted-foreground" aria-hidden="true"> {days.slice(0, 7).map((d) => ( <span key={d}>{headFmt.format(fromISO(d))}</span> ))} </div> <div ref={grid} role="grid" aria-label={monthFmt.format(fromISO(`${view}-01`))} onKeyDown={onGridKey} className="relative grid grid-cols-7 gap-y-0.5"> <span aria-hidden="true" className="pointer-events-none absolute left-0 top-0 size-9 rounded-full bg-primary" style={{ transform: `translate3d(${mark?.x ?? 0}px, ${mark?.y ?? 0}px, 0)`, opacity: mark ? 1 : 0, transition: reduced || !markReady ? "none" : `transform 420ms ${EASE_THROW}, opacity 200ms ${EASE_OUT}`, }} /> {days.map((d) => { const inMonth = monthOf(d) === view; const on = d === current; const ok = allowed(d); return ( <button key={d} type="button" role="gridcell" data-day={d} aria-selected={on} aria-label={dayFmt.format(fromISO(d))} aria-current={d === today ? "date" : undefined} tabIndex={d === focusDay ? 0 : -1} disabled={!ok} onClick={() => { commit(d); setDraft(null); setBadInput(false); close(true); }} className={`relative z-10 mx-auto flex size-9 items-center justify-center rounded-full text-[13px] tabular-nums cursor-pointer transition-colors duration-200 disabled:cursor-default disabled:opacity-30 ${INTERACTION_FOCUS} focus-visible:ring-offset-0 ${ on ? "text-primary-foreground" : inMonth ? "text-foreground hover:bg-accent" : "text-muted-foreground/50 hover:bg-accent" } ${d === today && !on ? "shadow-[inset_0_0_0_1px_var(--border)]" : ""}`} > {Number(d.slice(8))} </button> ); })} </div> <div className="mt-2 flex justify-between border-t border-border pt-2"> <button type="button" onClick={() => { const t = today ?? localToday(); commit(t); setDraft(null); close(true); }} className={`h-7 rounded-full px-2.5 text-[12.5px] font-medium text-foreground cursor-pointer hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}> Today </button> {current && ( <button type="button" onClick={() => { commit(null); setDraft(null); close(true); }} className={`h-7 rounded-full px-2.5 text-[12.5px] text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}> Clear </button> )} </div> </div>, document.body, )} </Field> );}export default DatePicker;"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * * label above, with an optional aside on the right (a count) * hint under the control while all is well * error takes the hint's place: the hint folds away, the * error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps { id: string; label?: React.ReactNode; /** Right side of the label row, e.g. a character count. */ aside?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; required?: boolean; className?: string; children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) { const ring = invalid ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]" : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]"; return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${ disabled ? "pointer-events-none opacity-50" : "" }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) { const auto = React.useId(); const base = id ?? auto; return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) { const ref = React.useRef<T>(null); const reduced = useInteractionReducedMotion(); React.useEffect(() => { if (!on || reduced) return; ref.current?.animate( [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }, ); }, [on, reduced]); return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) { const [kept, setKept] = React.useState(children); if (show && children !== kept) setKept(children); return ( <div className="grid" style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }} > <div className="min-h-0 overflow-hidden"> <p id={id} className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`} style={{ opacity: show ? 1 : 0, filter: show ? "blur(0px)" : "blur(4px)", transform: show ? "none" : "translateY(-4px)", transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`, }} > {show ? children : kept} </p> </div> </div> );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) { const reduced = useInteractionReducedMotion(); const ids = { hint: `${id}-hint`, error: `${id}-error` }; return ( <div className={`flex w-full flex-col ${className}`}> {(label || aside) && ( <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5"> {label && ( <label htmlFor={id} className="text-[13px] font-medium text-foreground"> {label} {required && ( <span aria-hidden="true" className="ml-0.5 text-muted-foreground"> * </span> )} </label> )} {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>} </div> )} {children} <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}> {hint} </Line> <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}> {error} </Line> </div> );}export default Field;"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * * place beside an anchor on a side, lined up start, centre * or end; flips to the other side when it doesn't fit * and shifts to stay `padding` inside the viewport; * follows scroll and resize * presence mounts on open, shows a frame later (so the entry * transitions run), hides, then unmounts after `exit` * dismiss Escape and a press outside both close it * theme the portal carries the anchor's theme tokens, so a * light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties { if (!anchor) return {}; const style = getComputedStyle(anchor); const vars: Record<string, string> = {}; for (const t of TOKENS) { const v = style.getPropertyValue(t); if (v) vars[t] = v; } return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement { x: number; y: number; side: Side; /** Where the surface grows from, for transform-origin. */ origin: string; ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({ open, anchor, point, floating, side = "bottom", align = "center", offset = 8, padding = 8,}: { open: boolean; anchor?: React.RefObject<Element | null>; point?: { x: number; y: number } | null; floating: React.RefObject<HTMLElement | null>; side?: Side; align?: Align; offset?: number; padding?: number;}): Placement { const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false }); React.useLayoutEffect(() => { if (!open) return; let frame = 0; const measure = () => { const el = floating.current; if (!el) return; const a = point ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 } : anchor?.current?.getBoundingClientRect(); if (!a) return; const w = el.offsetWidth; const h = el.offsetHeight; const vw = window.innerWidth; const vh = window.innerHeight; const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding }; const need = side === "top" || side === "bottom" ? h + offset : w + offset; const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side; let x: number; let y: number; if (s === "top" || s === "bottom") { y = s === "bottom" ? a.bottom + offset : a.top - offset - h; x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2; if (point) x = a.left; } else { x = s === "right" ? a.right + offset : a.left - offset - w; y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2; } x = Math.max(padding, Math.min(vw - padding - w, x)); y = Math.max(padding, Math.min(vh - padding - h, y)); // Grow from the point on the anchor's edge nearest the surface. const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`; const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`; const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true }; setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next)); }; const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(measure); }; measure(); const ro = new ResizeObserver(schedule); if (floating.current) ro.observe(floating.current); window.addEventListener("scroll", schedule, { capture: true, passive: true }); window.addEventListener("resize", schedule); return () => { cancelAnimationFrame(frame); ro.disconnect(); window.removeEventListener("scroll", schedule, { capture: true }); window.removeEventListener("resize", schedule); }; }, [open, anchor, point, floating, side, align, offset, padding]); return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) { const [mounted, setMounted] = React.useState(open); const [shown, setShown] = React.useState(false); if (open && !mounted) setMounted(true); React.useEffect(() => { if (open) { let inner = 0; const outer = requestAnimationFrame(() => { inner = requestAnimationFrame(() => setShown(true)); }); return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); }; } const timer = window.setTimeout(() => { setMounted(false); setShown(false); }, exit); return () => window.clearTimeout(timer); }, [open, exit]); return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) { const latest = React.useRef({ onDismiss, refs }); React.useEffect(() => { latest.current = { onDismiss, refs }; }); React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.stopPropagation(); latest.current.onDismiss("escape"); }; const onDown = (e: PointerEvent) => { const target = e.target as Node; if (latest.current.refs.some((r) => r.current?.contains(target))) return; latest.current.onDismiss("outside"); }; document.addEventListener("keydown", onKey); document.addEventListener("pointerdown", onDown, true); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onDown, true); }; }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties { const d = 6; const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`; return { opacity: shown ? 1 : 0, translate: shown || reduced ? "0 0" : shift, scale: shown || reduced ? "1" : "0.96", filter: shown || reduced ? "blur(0px)" : "blur(4px)", transition: reduced ? "opacity 120ms ease-out" : shown ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}` : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`, };}Registry item: /r/date-picker.json


