Frontile

DatePicker

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

import { DatePicker } from 'frontile';

Usage

import { DatePicker } from 'frontile';

<template>
  <div class='demo-stack'>
    <DatePicker @label='Start date' @placeholder='Pick a date' />
  </div>
</template>

Controlled vs Uncontrolled

@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>
}

Range Mode

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

Presets

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>
}

Custom Trigger Content

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>

Formatting

@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

@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>

Restricting Selectable Dates

@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>

Clearable

@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>

Forms

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>
}

Accessibility

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.

API

DatePicker

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.

Arguments

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 'none' so that a click anywhere in the field -- the icon included -- falls through to the trigger and opens the picker. The clear button opts back in on its own.

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 'auto' -- the calendar's own width is the meaningful one, and it changes with @visibleMonths, so a fixed width would clip the grid. 'trigger' matches the field's width; the named sizes are fixed.

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) Element object, 2) element id, 3) portal target name.

For element id, string must be prefixed with #. If no value is passed in, we will render to the closest unnamed portal target, parent portal or document.body.

transitionDuration number 200 Duration of the animation
value enum -

A { start, end } pair, each a Date or a yyyy-MM-dd string; end may be null while only the anchor is chosen. Synced the same way as single mode's @value: setting it replaces the displayed range, undefined is ignored, and the field updates on its own as the user picks. A Date, or the same yyyy-MM-dd string this component writes to its hidden input.

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 @value to come back. undefined is ignored, which is why a <form.Field>-bound picker — which always passes a value key — still honors @defaultValue before form data exists.

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.

Blocks

Name Type Default Description
value * Array -
calendar * Array -
footer * Array -
Released under MIT License - Created by Josemar Luedke