defuss-shadcn / Forms & Inputs / date-picker
Date PickerATM
A date selection input. Built on native <input type="date"> and <input type="datetime-local"> - the browser provides the calendar UI.
On this page (6)
§Date
Native date picker.
§Date and time
§With constraints
min / max hold for the calendar AND for typing: type 15 May 2030 into either field and leave it - the browser marks it out of range (:user-invalid), the border turns destructive and the .date-input-hint below states the allowed range with a warning sign. The hint is always there (aria-describedby), so the range is known before anyone types. The second field shows the invalid look directly (aria-invalid, as an app would flag a server-side error).
§Sizes
Same ladder as .input via data-size - md is 2.25rem, so date fields row-align with text inputs.
§States
Machine contract - verified against date-picker.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "" | Selected date (input.value, ISO YYYY-MM-DD; empty = none). |
minDate | string | — | "" | Lower bound of the picked range (native min attribute, ISO YYYY-MM-DD; empty clears). |
maxDate | string | — | "" | Upper bound of the picked range (native max attribute). |
disabled | boolean | true, false | false | Interaction off (native disabled attribute). |
§CSS view file
/* -- Date Picker component -------------------------------------- *//* Uses the .input class with type="date" or type="datetime-local" *//* This file adds date-specific overrides only. */@layer components { .date-input { /* see .input - border-box so the declared ladder height is the real box; unsized default = the ladder's md step (field standard) */ box-sizing: border-box; height: 2.25rem; width: 100%; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); padding: 0 0.75rem; font-size: 0.875rem; font-family: var(--font-sans); color: var(--foreground); outline: none; box-shadow: var(--shadow-xs); transition: border-color 150ms, box-shadow 150ms; cursor: pointer; &:focus { border-color: var(--ring); box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2); } /* Disabled = legible but inert: a muted surface and muted text inside the full-strength border - never an opacity fade that lets the box dissolve into the page. */ &:disabled { background-color: var(--muted); color: var(--muted-foreground); box-shadow: none; cursor: not-allowed; } /* -- Sizes: same ladder as .input (md = 2.25rem; the unsized default stays at the historical 2.5rem) so date fields sit in the same row rhythm as text inputs at every step. */ &[data-size="xs"] { height: 1.75rem; padding: 0 0.5rem; font-size: 0.75rem; } &[data-size="sm"] { height: 2rem; padding: 0 0.625rem; font-size: 0.8125rem; } &[data-size="md"] { height: 2.25rem; padding: 0 0.75rem; font-size: 0.875rem; } &[data-size="lg"] { height: 2.75rem; padding: 0 1rem; font-size: 1rem; } &[data-size="xl"] { height: 3.25rem; padding: 0 1.25rem; font-size: 1.125rem; } /* -- Out of range / invalid ---------------------------------- The browser already rejects a TYPED date outside min/max (validity rangeUnderflow / rangeOverflow) - the same rule the calendar popup enforces by disabling days. :user-invalid shows it once the user has interacted (an untouched empty field never turns red); aria-invalid lets an app flag server-side errors the same way. */ &:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) { border-color: var(--destructive); &:focus { border-color: var(--destructive); box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2); } } /* Style the calendar icon */ &::-webkit-calendar-picker-indicator { cursor: pointer; opacity: 0.6; transition: opacity 150ms; &:hover { opacity: 1; } } } /* -- Range hint -------------------------------------------------- States the allowed dates up front (point aria-describedby at it, so it is announced with the field) and turns into the error when the field holds a date outside them: destructive colour + a warning sign, so the state never rests on colour alone. Always rendered - no show/hide, no live-region timing, no JavaScript. Place it after the input. */ .date-input-hint { margin: 0.375rem 0 0; font-size: 0.8125rem; color: var(--muted-foreground); } .date-input:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) ~ .date-input-hint { color: var(--destructive); &::before { content: '⚠\FE0E '; font-weight: 600; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .date-input, .date-input *, .date-input::before, .date-input::after, .date-input *::before, .date-input *::after, .date-input::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}/* Forced colors (Windows High Contrast): the destructive colour is replaced by system colours - the invalid field keeps a distinct border and the hint keeps its warning sign. */@media (forced-colors: active) { @layer components { .date-input:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) { border-color: LinkText; border-width: 2px; } .date-input:is([aria-invalid="true"], :user-invalid:not(form[data-validate="submit"]:not([data-submitted]) *)) ~ .date-input-hint { color: LinkText; } }}Comments, ideas or improvements? Edit this page's source on GitHub