Type or select a date.
Overview
The date picker lives in @flatpay-dk/ui and exports two components: DatePicker for a single date and DateRangePicker for a range. Both share one input-height surface and three variants — button (the default), stepper, and typeable — picked by interaction, not by size. Range type drops the typeable variant.
Format follows the variant, not the developer
The button + stepper variants display values in d MMM yyyy (e.g. 8 Feb 2026) — the Medium format from the Date and time content guidelines. The typeable variant uses dd/MM/yyyy so the displayed value round-trips through its parser. Both go through Intl.DateTimeFormat; override per instance with the format prop on the button + stepper variants.
Variants
Three variants picked by interaction. button is the default — a clickable trigger that opens the calendar popover and displays the current selection. stepper adds prev/next buttons on either side for sequentially scanning through dates or preset periods. typeable swaps the trigger for a text input that accepts dd/MM/yyyy directly — the right reach for known dates the user has memorised.
variant — button
The default. Trigger displays the selected date in d MMM yyyy and opens the calendar popover.
variant — stepper
Trigger flanked by prev/next buttons. Use for single dates or preset periods (this week, this month, YTD).
DD/MM/YYYY
variant — typeable
Type the date directly in dd/MM/yyyy. Useful for known dates the user has memorised — birthdays, expiries.
Types
One date, or two. The range type pairs start and end on the same trigger and on the popover above the calendar. Same-month ranges compress to a single month label — 8 – 18 Feb 2026 — so the chip stays short.
type — single
One date. Use when the user picks a single moment — a payout day, a launch, a delivery.
type — range
A start and end. Use for periods — a settlement window, a reporting interval, a campaign.
Stepper
Stepper buttons attach to either side of the trigger. On a single date they step by one day. On a range, they shift the entire window by its length — a 7-day window jumps a week back or forward.
Stepper is for single dates and preset periods only
Reach for stepper on a single date or a preset period — this week, last week, this month, year-to-date — where stepping lands on something the user recognises. An arbitrary 41-day window stepped by 41 days is rarely what anyone wants; reach for the popover instead. The component allows it for flexibility, but the rule is contextual.
single — stepper
Steps the value by one day. Use on dashboards where the user is scanning sequentially.
range — stepper (preset only)
Reserved for preset periods — this week, last week, this month, YTD. The chrome is the same; the rule is contextual.
States
Six visible states — empty, filled, hover, active (popover open), focus, disabled, error. Hover, active, and focus are CSS-driven; you get them for free on the live component. The matrix below holds empty, filled, disabled, and error still so they're reviewable side-by-side.
Single date
Type or select a date.
single · empty
No value yet. Label sits where the value will land. Helper line tells the user what to type or pick.
Type or select a date.
single · filled
Label floats up to 12 px; the value sits below it in foreground at 16 px, tabular-nums.
Type or select a date.
single · disabled
Container fills bg-disabled and text drops to text-disabled-foreground. Helper text stays visible.
Validation failed
Type or select a date.
single · error
Pink fill, dark-rose border, validation message above the helper line.
DD/MM/YYYY
typeable · empty
Input accepts dd/MM/yyyy with /, ., or - separators. Calendar icon opens the popover as a fallback.
DD/MM/YYYY
typeable · filled
Value displayed in dd/MM/yyyy so it round-trips through the parser cleanly.
Range
range · filled
The trigger compresses the range — same year drops to 8 Feb – 18 Mar 2026.
range · stepper
Stepper buttons step the entire range forward or back by its length. Reserved for preset periods.
This filter is locked while the report is loading.
range · disabled
Same container fade as the single picker. Helper text stays visible to explain why.
Validation failed
Pick a start and end date.
range · error
Pink fill, dark-rose border, validation message above the helper line.
Calendar popover
Open the trigger to land on the calendar. The grid runs Monday-first; today carries a 1 px ring; the selected date fills with charcoal. Range mode draws a 6 % black bar between endpoints so the eye can read the period at a glance. The footer commits the draft — Cancel reverts, Apply writes the value back.
Open the trigger to expand
Anatomy
The pieces of the trigger surface, shared across variants.
Floating label
Inter Tight 12 px / muted-foreground. Sits above the value when the field is filled or focused; collapses into the placeholder when empty.
Value
Inter Tight 16 px / foreground, tabular-nums + slashed-zero. Button + stepper variants display d MMM yyyy (e.g. 8 Feb 2026); typeable displays dd/MM/yyyy (e.g. 08/02/2026).
Calendar icon
24 × 24 affordance from the Material Outlined set, drawn in muted-foreground. On the typeable variant it's a button that opens the popover; on button + stepper the entire trigger opens the popover.
Behavior
The popover is not a modal. Click outside to dismiss; Esc dismisses and returns focus to the trigger. The grid is keyboard-first.
| Key | Action |
|---|---|
| EnterSpace | Open the popover from the trigger. |
| Esc | Close the popover; return focus to the trigger. |
| Tab | Advance to the next focusable control; closes the popover. |
| ←→ | Move focus by one day inside the calendar grid. |
| ↑↓ | Move focus by one week. |
| HomeEnd | Jump to the start or end of the focused week. |
| PageUpPageDown | Step the displayed month back or forward. |
| Shift+PageUpShift+PageDown | Step by a year. |
Accessibility
The trigger announces itself as a date control with aria-haspopup="dialog" and exposes the live value through aria-label. The day grid uses role="grid" with each cell as a button, marks today with aria-current="date", and pairs selection with aria-pressed. Day labels are spelled out in full for screen readers — “Friday, 8 February 2026” — so meaning never lives only in colour.
Validation needs words, not just chrome
The error state shifts the surface to pink and adds an error_outline glyph, but those are reinforcement. Always pair a truthy error with a string — a colour-blind user reads the message, not the chrome.
Code
Both pickers support controlled and uncontrolled value flows, and accept min / max bounds.
Live demo
Single date — controlled
tsx
import { useState } from "react";
import { DatePicker } from "@flatpay-dk/ui";
export function PayoutDate() {
const [value, setValue] = useState<Date | null>(null);
return (
<DatePicker
label="Payout date"
value={value}
onValueChange={setValue}
min={new Date()}
helpText="Funds settle the next business day."
/>
);
}Typeable — for known dates
tsx
import { DatePicker } from "@flatpay-dk/ui";
export function DateOfBirth() {
return (
<DatePicker
variant="typeable"
label="Date of birth"
max={new Date()}
helpText="DD/MM/YYYY"
/>
);
}Range — uncontrolled with stepper (preset period)
tsx
import { DateRangePicker } from "@flatpay-dk/ui";
// Preset: this week
const today = new Date();
const monday = new Date(today);
monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
export function WeeklyReportRange() {
return (
<DateRangePicker
variant="stepper"
label="This week"
defaultValue={{ start: monday, end: sunday }}
onValueChange={(range) => console.log(range)}
/>
);
}Custom format
tsx
import { DatePicker } from "@flatpay-dk/ui";
const isoFormat = (d: Date) => d.toISOString().slice(0, 10);
<DatePicker
label="Date"
defaultValue={new Date()}
format={isoFormat}
/>;Best practices
Pick the variant by interaction, not by mood. Use stepper only on single dates or preset periods. Reach for typeable when the user knows the date by heart. Let validation explain itself in words.
Do
Use typeable for known dates the user has memorised — birthdays, expiries, anniversaries.
Don't
Don't reach for typeable when the user is browsing for a date — the calendar is faster than guessing the format.
Validation failed
Do
Pair the error prop with a string. Colour reinforces; the message explains.
Don't
Don't pass error={true} on its own — the surface goes pink with no recovery path.
Props
DatePicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Date | null | — | Controlled value. Pair with onValueChange. Use defaultValue for uncontrolled. |
| defaultValue | Date | null | null | Initial value when uncontrolled. |
| onValueChange | (next: Date | null) => void | — | Fires when the user applies a date or steps the value. |
| variant | 'button' | 'stepper' | 'typeable' | 'button' | Picks the trigger interaction. button opens the popover; stepper adds prev/next; typeable accepts dd/MM/yyyy directly. |
| label | string | 'Date' | Floating label. Sits where the value lands when empty; floats up when filled or focused. |
| placeholder | string | 'Select a date' (button + stepper) / 'DD/MM/YYYY' (typeable) | Shown when the value is null and the label has floated. |
| helpText | ReactNode | — | Helper line below the input. Hidden when an error string is set. |
| error | string | boolean | — | Truthy enters the error visual state. Always supply a string so the message lives next to the cause. |
| disabled | boolean | false | Inert surface. |
| min | Date | — | Earliest selectable day. |
| max | Date | — | Latest selectable day. |
| format | (date: Date) => string | — | Override the displayed value on button + stepper variants. Defaults to d MMM yyyy. Ignored by typeable, which always uses dd/MM/yyyy so its display round-trips through the parser. |
| today | Date | — | Override the day marked as today (useful in tests/screenshots). |
DateRangePicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value | { start: Date; end: Date } | null | — | Controlled range value. |
| defaultValue | { start: Date; end: Date } | null | null | Initial range when uncontrolled. |
| onValueChange | (next: DateRange | null) => void | — | Fires when the user applies a range or steps it forward / back. |
| variant | 'button' | 'stepper' | 'button' | Picks the trigger interaction. stepper adds prev/next that shift the range by its length — reserved for preset periods (this week, this month, YTD). |
| startLabel | string | 'Start date' | Label for the start cell in the popover summary. |
| endLabel | string | 'End date' | Label for the end cell in the popover summary. |
| format | (range: DateRange) => string | — | Override the trigger's displayed range. Defaults compress shared month/year. |
| label | string | — | Floating label for the input size. |
| placeholder | string | 'Select dates' | Shown when the range is null. |
| helpText | ReactNode | — | Helper line below the input. |
| error | string | boolean | — | Validation visual + message. |
| disabled | boolean | false | Inert surface. |
| min | Date | — | Earliest selectable day. |
| max | Date | — | Latest selectable day. |