Components · Forms

Date picker

Pick a single date or a range. One input-height size, three variants — button, stepper, typeable — and a calendar popover that does the work. Numerals are tabular; dates are formatted European-first.

Documentedby Derek Fidler

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).

Date of birth

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.

Date of birth

DD/MM/YYYY

typeable · empty

Input accepts dd/MM/yyyy with /, ., or - separators. Calendar icon opens the popover as a fallback.

Date of birth

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.

  1. 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.

  2. 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).

  3. 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.

KeyAction
EnterSpaceOpen the popover from the trigger.
EscClose the popover; return focus to the trigger.
TabAdvance to the next focusable control; closes the popover.
Move focus by one day inside the calendar grid.
Move focus by one week.
HomeEndJump to the start or end of the focused week.
PageUpPageDownStep the displayed month back or forward.
Shift+PageUpShift+PageDownStep 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
Date range (preset)
single: 2026-02-08·range: 2026-02-08 → 2026-03-18

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.

Date of birth

Do

Use typeable for known dates the user has memorised — birthdays, expiries, anniversaries.

Pick a meeting day

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

PropTypeDefaultDescription
valueDate | nullControlled value. Pair with onValueChange. Use defaultValue for uncontrolled.
defaultValueDate | nullnullInitial value when uncontrolled.
onValueChange(next: Date | null) => voidFires 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.
labelstring'Date'Floating label. Sits where the value lands when empty; floats up when filled or focused.
placeholderstring'Select a date' (button + stepper) / 'DD/MM/YYYY' (typeable)Shown when the value is null and the label has floated.
helpTextReactNodeHelper line below the input. Hidden when an error string is set.
errorstring | booleanTruthy enters the error visual state. Always supply a string so the message lives next to the cause.
disabledbooleanfalseInert surface.
minDateEarliest selectable day.
maxDateLatest selectable day.
format(date: Date) => stringOverride 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.
todayDateOverride the day marked as today (useful in tests/screenshots).

DateRangePicker

PropTypeDefaultDescription
value{ start: Date; end: Date } | nullControlled range value.
defaultValue{ start: Date; end: Date } | nullnullInitial range when uncontrolled.
onValueChange(next: DateRange | null) => voidFires 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).
startLabelstring'Start date'Label for the start cell in the popover summary.
endLabelstring'End date'Label for the end cell in the popover summary.
format(range: DateRange) => stringOverride the trigger's displayed range. Defaults compress shared month/year.
labelstringFloating label for the input size.
placeholderstring'Select dates'Shown when the range is null.
helpTextReactNodeHelper line below the input.
errorstring | booleanValidation visual + message.
disabledbooleanfalseInert surface.
minDateEarliest selectable day.
maxDateLatest selectable day.