A date field: segments the value can be typed into, and a Calendar
in a popover behind a calendar button. Use it for picking a single day or, with
@mode="range", a start/end range. @isEditable={{false}} swaps the segments for a button
trigger showing the formatted value.
The segments are the same ones DateInput renders, and behave the same way — that page documents typing, pasting and the keyboard in full.
import { DatePicker } from 'frontile';
import { DatePicker } from 'frontile';
<template>
<div class='demo-stack'>
<DatePicker @label='Start date' />
</div>
</template>
@isEditable defaults to true, which renders the segmented field above: the value is
typed, and the calendar button at the end of the field opens the popover.
@isEditable={{false}} restores the button trigger, where the whole field is one
<button> showing the formatted value and clicking anywhere in it opens the calendar.
@placeholder, @formatOptions and the :value block all describe that button — a
segmented field has its own per-segment placeholders and takes its format from
@formatOptions differently, as Formatting below explains.
import { DatePicker } from 'frontile';
<template>
<div class='grid gap-4 md:grid-cols-2'>
<DatePicker @label='Editable' @locale='en-US' />
<DatePicker
@label='Button trigger'
@locale='en-US'
@isEditable={{false}}
@placeholder='Pick a date'
/>
</div>
</template>
@value accepts a Date or a yyyy-MM-dd string, and @defaultValue seeds an uncontrolled
picker. @onChange always hands back Dates, regardless of which form @value was given in.
The field keeps its own selection and treats @value as something to sync from: setting it
replaces what is displayed, and picking a date updates the field immediately without waiting
for @value to come back. Passing undefined changes nothing, so a picker whose @value has
no data yet still honours @defaultValue. This is how Select behaves, and it is what lets
the field work inside a <Form>, where @value is bound to data the field is itself the only
source of.
Tue Jan 20 2026
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { DatePicker } from 'frontile';
export default class ControlledDatePicker extends Component {
@tracked value: Date | null = new Date(2026, 0, 20);
handleChange = (value: Date | null) => {
this.value = value;
};
get valueLabel(): string {
return this.value ? this.value.toDateString() : 'No date selected';
}
<template>
<div class='demo-stack'>
<DatePicker
@label='Start date'
@value={{this.value}}
@onChange={{this.handleChange}}
/>
<p class='text-sm text-neutral-soft'>{{this.valueLabel}}</p>
</div>
</template>
}
@mode="range" switches the calendar and the value shape to { start, end }. @visibleMonths
shows more than one month at a time, which is typical for a range picker.
An editable range renders two groups of segments with a separator between them, one group
per end. Both are typed into, and pasting text that reads as two dates — 2026-01-20 – 2026-02-09 — into the start group fills both. A range is anchored at its start: segments
that compose no start compose no range at all, however complete the end group is, and
clearing the start reports null.
The popover sizes itself to the calendar, so showing a second month widens it automatically —
there is nothing to adjust. Pass @popoverSize if you need a fixed width ("sm", "md",
"lg", "xl") or want it to match the field ("trigger"); note that a fixed width narrower
than the grid will clip it.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { DatePicker } from 'frontile';
import type { DateRange } from 'frontile';
export default class RangeDatePicker extends Component {
@tracked value: DateRange | null = {
start: new Date(2026, 0, 20),
end: new Date(2026, 1, 9)
};
handleChange = (value: DateRange | null) => {
this.value = value;
};
<template>
<div class='demo-stack'>
<DatePicker
@label='Stay'
@mode='range'
@visibleMonths={{2}}
@value={{this.value}}
@onChange={{this.handleChange}}
/>
</div>
</template>
}
A range with only its start chosen leaves the calendar open: the field shows the anchor in its start group and waits for the second click.
There is no @presets argument. Presets are ordinary buttons composed into the :footer
block, which yields { setValue, close, value, isOpen }. setValue behaves exactly like
clicking a day: it fires @onChange and closes the popover once the value is complete.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper';
import { Button, DatePicker } from 'frontile';
function startOfToday(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function daysFromToday(days: number): Date {
const date = startOfToday();
date.setDate(date.getDate() + days);
return date;
}
export default class DatePickerPresets extends Component {
@tracked value: Date | null = null;
presets = [
{ label: 'Today', date: startOfToday() },
{ label: 'Tomorrow', date: daysFromToday(1) },
{ label: 'In a week', date: daysFromToday(7) }
];
handleChange = (value: Date | null) => {
this.value = value;
};
<template>
<div class='demo-stack'>
<DatePicker
@label='Due date'
@value={{this.value}}
@onChange={{this.handleChange}}
>
<:footer as |f|>
{{#each this.presets as |preset|}}
<Button
@variant='subtle'
@color='primary'
@size='xs'
@onPress={{fn f.setValue preset.date}}
>{{preset.label}}</Button>
{{/each}}
<Button
@variant='plain'
@size='xs'
@class='ml-auto'
@onPress={{f.close}}
>Close</Button>
</:footer>
</DatePicker>
</div>
</template>
}
The :value block replaces everything the button trigger renders, receiving
{ value, formatted, isEmpty }. It describes the @isEditable={{false}} path only; an
editable field renders segments and no trigger for the block to fill. Because the block may
render nothing readable — an icon alone, for instance — the trigger is given an explicit
aria-label composed from @label and the formatted value whenever the block is
supplied.
import { DatePicker } from 'frontile';
const value = new Date(2026, 0, 20);
<template>
<div class='demo-stack'>
<DatePicker
@label='Start date'
@value={{value}}
@locale='en-US'
@isEditable={{false}}
>
<:value as |v|>
{{#if v.isEmpty}}
<span class='text-neutral-soft'>No date chosen</span>
{{else}}
<span class='font-medium'>{{v.formatted}}</span>
{{/if}}
</:value>
</DatePicker>
</div>
</template>
@formatOptions is passed to Intl.DateTimeFormat alongside @locale. On the button
trigger it formats the value for display and defaults to { dateStyle: 'medium' }.
import { DatePicker } from 'frontile';
const value = new Date(2026, 0, 20);
const full = { dateStyle: 'full' } as const;
const numeric = { day: '2-digit', month: '2-digit', year: 'numeric' } as const;
<template>
<div class='demo-stack'>
<div class='grid gap-4 md:grid-cols-3'>
<DatePicker
@label='Default'
@value={{value}}
@locale='en-US'
@isEditable={{false}}
/>
<DatePicker
@label='Full'
@value={{value}}
@locale='en-US'
@isEditable={{false}}
@formatOptions={{full}}
/>
<DatePicker
@label='Numeric'
@value={{value}}
@locale='en-US'
@isEditable={{false}}
@formatOptions={{numeric}}
/>
</div>
</div>
</template>
On the editable field the same argument decides which segments appear and in what order,
and defaults to { year: 'numeric', month: '2-digit', day: '2-digit' } instead — a textual
month has no numeric segment to type into. A filled segment always shows its full width, so
month: 'numeric' and month: '2-digit' render the same field. A textual @formatOptions, including
the { dateStyle: 'medium' } the button trigger defaults to, falls back to a numeric month
and warns; only the month is replaced, so a format naming other fields keeps them. A
dateStyle preset names no fields at all and so falls back to the numeric default in full.
import { DatePicker } from 'frontile';
const value = new Date(2026, 0, 5);
const monthAndDay = { month: '2-digit', day: '2-digit' } as const;
<template>
<div class='grid gap-4 md:grid-cols-2'>
<DatePicker @label='Default segments' @value={{value}} @locale='en-US' />
<DatePicker
@label='Month and day'
@value={{value}}
@locale='en-US'
@formatOptions={{monthAndDay}}
/>
</div>
</template>
@color picks the semantic color the calendar uses for the selected day and,
in range mode, the band between the two ends. It defaults to primary.
It colors the calendar only — the field itself is drawn from the form field styles it shares with every other input, so a picker still looks like the rest of the form.
import { DatePicker } from 'frontile';
import { array } from '@ember/helper';
const jan20 = new Date(2026, 0, 20);
<template>
<div class='flex flex-wrap gap-4'>
{{#each (array 'primary' 'success' 'warning' 'danger') as |color|}}
<DatePicker
@label='{{color}}'
@color={{color}}
@defaultValue={{jan20}}
@locale='en-US'
/>
{{/each}}
</div>
</template>
@minValue and @maxValue bound the range of selectable days; @isDateUnavailable marks
individual days unselectable within that range, such as weekends or already-booked nights.
import { DatePicker } from 'frontile';
const min = new Date(2026, 0, 1);
const max = new Date(2026, 0, 31);
const defaultValue = new Date(2026, 0, 20);
const isWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6;
<template>
<div class='demo-stack'>
<DatePicker
@label='Appointment date'
@defaultValue={{defaultValue}}
@locale='en-US'
@minValue={{min}}
@maxValue={{max}}
@isDateUnavailable={{isWeekend}}
/>
</div>
</template>
@isClearable adds a clear button once there is a value. It never renders on a disabled or
read-only picker.
On the editable field the clear button sits beside the calendar button, which is the only way to open the popover there. On the button trigger the two are either/or: the trigger itself opens the calendar, so the icon is decorative and the clear button takes its place.
import { DatePicker } from 'frontile';
const defaultValue = new Date(2026, 0, 20);
<template>
<div class='grid gap-4 md:grid-cols-2'>
<DatePicker
@label='Editable'
@defaultValue={{defaultValue}}
@isClearable={{true}}
/>
<DatePicker
@label='Button trigger'
@isEditable={{false}}
@placeholder='Pick a date'
@defaultValue={{defaultValue}}
@isClearable={{true}}
/>
</div>
</template>
Inside a <Form>, a DatePicker given @name submits one field: the wire value is the same
yyyy-MM-dd string @value accepts. A range picker submits two dotted names,
{{@name}}.start and {{@name}}.end, which Form unflattens into one nested object.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Form, DatePicker, type FormResultData } from 'frontile';
export default class DatePickerFormExample extends Component {
@tracked submitted: FormResultData['data'] | null = null;
handleSubmit = ({ data }: FormResultData) => {
this.submitted = data;
};
<template>
<div class='demo-stack'>
<Form @onSubmit={{this.handleSubmit}}>
<DatePicker @label='Start date' @name='start' />
<button type='submit'>Save</button>
</Form>
{{#if this.submitted}}
<p class='text-sm'>Submitted: {{this.submitted.start}}</p>
{{/if}}
</div>
</template>
}
<form.Field> yields both a bound DatePicker and a bound DateRangePicker — the same
component, curried to @mode="range". A range field arrives at onSubmit as
{ [name]: { start, end } }.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Form, type FormResultData } from 'frontile';
export default class DateRangePickerFieldExample extends Component {
@tracked submitted: FormResultData['data'] | null = null;
handleSubmit = ({ data }: FormResultData) => {
this.submitted = data;
};
<template>
<div class='demo-stack'>
<Form @onSubmit={{this.handleSubmit}} as |form|>
<form.Field @name='stay' as |field|>
<field.DateRangePicker @label='Stay' @locale='en-US' />
</form.Field>
<button type='submit'>Save</button>
</Form>
{{#if this.submitted}}
<p class='text-sm'>
Submitted: {{this.submitted.stay.start}} – {{this.submitted.stay.end}}
</p>
{{/if}}
</div>
</template>
}
The field's parts carry data-part attributes, and state that CSS can select on:
| Element | Attributes |
|---|---|
[data-part="base"] |
data-component="date-picker" |
[data-part="inner-container"] |
data-invalid, data-disabled — on the segmented path this is the element drawing the border, background and focus ring |
[data-part="group"] |
data-invalid, data-readonly; one per end in range mode |
[data-part="segment"] |
data-type (year, month or day), data-placeholder, data-disabled |
[data-part="literal"] |
the separators inside a group |
[data-part="separator"] |
between the two groups of an editable range |
[data-part="calendar-button"] |
the button opening the popover, on the segmented path |
[data-part="clear-button"] |
rendered only while @isClearable has something to clear |
[data-part="input"] |
the button trigger, under @isEditable={{false}} |
@classes takes the matching slots, including group, segment, literal, separator
and calendarButton.
| Element | What it exposes |
|---|---|
| Segment | role="spinbutton" with aria-label (month, day, year, overridable through @segmentLabels), aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext — which is what makes a month announce as "January" rather than "1". Carries the field's aria-invalid and aria-readonly. |
| Segment group | role="group", named by @label and described by @description and any feedback. In range mode there are two, named "<label> start" and "<label> end". |
| Calendar button | A <button> labelled "Choose date, <label>", carrying the aria-haspopup, aria-expanded and aria-controls the popover trigger applies. |
| Trigger | Under @isEditable={{false}}, a <button> carrying the same popover trigger attributes. When a :value block is supplied, it also carries an explicit aria-label composed from @label and the formatted value, since the block's content may not be readable text on its own. |
| Popover content | role="dialog", labeled by @label. |
| Calendar grid | Labeled by the field's own id, so the default calendar is always labelled. A consumer rendering their own calendar from the :calendar block must pass @id to DatePicker for the grid to be labelled — the block's labelledBy reflects @id, not the id FormControl would otherwise generate. |
| Clear button | Announced as "Clear". |
The segments take the same keys as DateInput, and every one of them is a tab stop — three for a single field, six for a range.
role="group" supports neither aria-invalid nor aria-readonly in ARIA 1.2, so the
group carries data-invalid and data-readonly for styling while the real ARIA sits on
the segments.
A role="group" is not a labelable element, so the visible <label> does not associate
with the segments through for. Each group is named by aria-label instead, derived from
@label.
Opening the picker moves focus onto the selected (or today's) day inside the grid. Escape
closes the calendar and returns focus to the segment that had it — or to the button trigger
— as does completing a selection or clicking outside. @onBlur fires only once focus
leaves the whole control — the field and its popover — not on the way into the calendar.
The segmented trigger cannot be filled with fillIn, which fails silently rather than
throwing. Use fillDate (or fillDateRange for @mode="range") from
frontile/test-support; see DateInput for the details and the
reason.
import { fillDate, fillDateRange } from 'frontile/test-support';
await fillDate('[data-test-due]', '2026-01-20');
await fillDateRange('[data-test-trip]', '2026-01-20', '2026-01-25');
With @isEditable={{false}} there is nothing to type into: click the trigger and pick
from the calendar, as before.
Element: HTMLDivElement
A date field: segments the value can be typed into, a calendar button that
opens a calendar in a popover, and -- under @isEditable={{false}} -- the
older button trigger showing the formatted value instead. @mode="range"
switches both the calendar and the value shape to a { start, end } range,
and -- when editable -- renders two groups of segments with a separator
between them.
| Name | Type | Default | Description |
|---|---|---|---|
captionLayout
|
enum
|
'label'
|
'label' renders the plain month/year caption; 'dropdown' swaps it for
a native month <select> plus a year trigger that opens a year-grid
picker.
|
classes
|
SlotsToClasses<'icon' | 'base' | 'innerContainer' | 'startContent' | 'endContent' | 'input' | 'clearButton' | 'separator' | 'placeholder' | 'group' | 'footer' | 'segment' | 'literal' | 'calendarButton' | 'calendar'>
|
- | |
closeOnEscapeKey
|
boolean
|
true
|
Whether to close when the escape key is pressed |
closeOnOutsideClick
|
boolean
|
true
|
Whether to close when the area outside (the backdrop) is clicked |
color
|
enum
|
'primary'
|
The color used for the selected day and the range band. |
defaultValue
|
enum
|
- |
Seeds the range before any @value is supplied.
Seeds the value before any @value is supplied.
|
description
|
string
|
- |
Help text rendered between the label and the control, and referenced by the
ids describedBy returns.
|
didClose
|
function
|
- | Callback when closing has finished, including any exit transition. |
disableTransitions
|
boolean
|
false
|
Disable css transitions |
endContentPointerEvents
|
enum
|
'none', or 'auto' when the field is segmented
|
Whether the cluster at the end of the field (the calendar icon, or the clear button) receives pointer events. Defaults to The segmented path defaults to |
errors
|
enum
|
- |
Validation messages for the field. A non-empty value also marks the control
invalid, and an array is joined with ; when displayed.
|
fixedWeeks
|
boolean
|
false
|
Renders six week rows in every month, so the calendar keeps the same height as you page between months of different lengths. |
flipOptions
|
{ mainAxis?: boolean; crossAxis?: boolean | 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' | 'initialPlacement'; fallbackAxisSideDirection?: 'start' | ... 1 more ... | 'none'; ... 5 more ...; boundary?: Boundary; }
|
- | Options for the floating-ui flip middleware, which moves the content to the opposite side when it would overflow the viewport. |
formatOptions
|
Object
|
{ dateStyle: 'medium' }
|
How the value is rendered in the trigger. Localized with @locale.
|
id
|
string
|
- | The unique identifier for the control. |
inputSize
|
enum
|
- |
The size of the field. Matches Select's @inputSize.
|
isClearable
|
boolean
|
false
|
Whether a clear button replaces the calendar icon when there is a value. |
isDateUnavailable
|
function
|
- |
Marks a date as present but unselectable -- a holiday, a booked night.
Distinct from @minValue/@maxValue, which put a date out of range
entirely.
|
isDisabled
|
boolean
|
false
|
Whether the field is disabled. FormControl passes this through for styling;
the control it wraps is responsible for the disabled attribute.
|
isEditable
|
boolean
|
true
|
Whether the value can be typed into segments.
|
isInvalid
|
boolean
|
false
|
Marks the control invalid without supplying messages, for validation that is reported elsewhere. |
isReadOnly
|
boolean
|
false
|
Allows paging between months but blocks selecting a day. Unlike
@isDisabled, the days stay focusable so the calendar can still be read
with the keyboard.
|
isRequired
|
boolean
|
false
|
Whether the field is required. Adds an asterisk to the label; it does not
set the required attribute on the control itself.
|
label
|
string
|
- |
The label text rendered above the control and associated with it via for.
Use the :label block instead when the label needs markup.
|
locale
|
string
|
- |
BCP-47 tag. All human-readable text is produced by Intl from this.
|
maxValue
|
Object
|
- | Latest selectable date. Also clamps month navigation. |
middleware
|
Array
|
- |
Additional floating-ui middleware for the popover positioning the
calendar, beyond what placement, offsetOptions, flipOptions, and
shiftOptions cover. Forwarded to Popover's own @middleware.
|
minValue
|
Object
|
- | Earliest selectable date. Also clamps month navigation. |
mode
|
enum
|
'single'
|
Switches the picker, and the calendar it wraps, to range selection.
Selects a single day. Set @mode="range" for a start/end range instead.
|
name
|
string
|
- | The name the value submits under. See the hidden inputs in date-picker.gts. |
offsetOptions
|
enum
|
5
|
|
onBlur
|
function
|
- | Fires when focus leaves the trigger and the popover. |
onChange
|
function
|
- |
Fires with the { start, end } range, or null after clearing.
Fires with the picked Date, or null after clearing.
|
placeholder
|
string
|
- | Text shown in the trigger when there is no value. |
placeholderValue
|
Object
|
today
|
Where ArrowUp on an empty segment starts. Only read on the segmented path. |
placement
|
enum
|
'bottom-start'
|
Placement of the menu when open |
popoverSize
|
enum
|
'auto'
|
The width of the popover holding the calendar. Defaults to |
renderInPlace
|
boolean
|
false
|
Whether to render in place or in the specified/default destination |
segmentLabels
|
Partial<Record<SegmentType, string>>
|
{ year: 'year', month: 'month', day: 'day' }
|
The accessible names of the segments, for localizing them. |
shiftOptions
|
{ mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; padding?: Padding; limiter?: { ...; }; boundary?: Boundary; }
|
- | Options for the floating-ui shift middleware, which nudges the content along its axis to keep it in view. |
showOutsideDays
|
boolean
|
`true` when a single month is visible, `false`
once `@visibleMonths` is greater than one -- otherwise a boundary date
would render twice, once per adjacent grid.
|
Whether days from the adjacent month fill out a grid's leading/trailing weeks. |
strategy
|
enum
|
'absolute'
|
|
target
|
enum
|
- |
The target where to render the portal.
There are 3 options: 1) For element id, string must be prefixed with |
transitionDuration
|
number
|
200
|
Duration of the animation |
value
|
enum
|
- |
A The field keeps its own selection and syncs from this argument: setting
it replaces what is displayed, while picking a date updates the field
immediately rather than waiting for |
visibleMonths
|
number
|
1
|
How many months to render side by side, starting from the visible month. |
weekStartsOn
|
enum
|
- |
Overrides the first day of week implied by @locale.
|
| Name | Type | Default | Description |
|---|---|---|---|
value
*
|
Array
|
- | |
calendar
*
|
Array
|
- | |
footer
*
|
Array
|
- |