A date field: a button trigger showing the formatted value, and a Calendar
in a popover. Use it for picking a single day or, with @mode="range", a start/end range.
import { DatePicker } from 'frontile';
import { DatePicker } from 'frontile';
<template>
<div class='demo-stack'>
<DatePicker @label='Start date' @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.
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 that only has its start chosen stays open — the trigger shows the anchor alone until a second click completes it.
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'
@placeholder='Pick a 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 trigger renders, receiving
{ value, formatted, isEmpty }. 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'>
<: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, 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' />
<DatePicker
@label='Full'
@value={{value}}
@locale='en-US'
@formatOptions={{full}}
/>
<DatePicker
@label='Numeric'
@value={{value}}
@locale='en-US'
@formatOptions={{numeric}}
/>
</div>
</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 swaps the calendar icon for a clear button once there is a value. It never
renders on a disabled picker.
import { DatePicker } from 'frontile';
const defaultValue = new Date(2026, 0, 20);
<template>
<div class='demo-stack'>
<DatePicker
@label='Start date'
@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>
}
| Element | What it exposes |
|---|---|
| Trigger | A <button> with aria-haspopup="dialog". 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". |
Opening the picker moves focus onto the selected (or today's) day inside the grid. Escape
closes the calendar and returns focus to the trigger, as does completing a selection or
clicking outside. @onBlur fires only once focus leaves the whole control — the trigger and
its popover — not on the way into the calendar.
Element: HTMLDivElement
A date field: a button trigger showing the formatted value, and a calendar
in a popover. @mode="range" switches both the calendar and the value shape
to a { start, end } range.
| 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<'base' | 'footer' | 'input' | 'icon' | 'innerContainer' | 'startContent' | 'endContent' | 'clearButton' | 'placeholder' | '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'
|
Whether the cluster at the end of the field (the calendar icon, or the clear button) receives pointer events. 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
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean | 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' | 'initialPlacement'; fallbackAxisSideDirection?: 'start' | ... 1 more ... | 'none'; ... 4 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.
|
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. |
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 |
shiftOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; 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
|
- |