Frontile

Calendar

A month-grid date picker for choosing a single day or a range, with full keyboard navigation and localization through Intl. Calendar renders no popover, trigger, or text input of its own—you can compose it with an input and popover to build a date picker.

Import

import { Calendar } from 'frontile/collections';

Usage

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

const today = new Date();

<template><Calendar @defaultValue={{today}} /></template>

The first visible month is resolved in this order: @defaultMonth, then the month of @defaultValue, then the month of @value, then today. A calendar seeded with a selection opens showing that selection rather than today.

Anatomy

Calendar provides four optional named blocks for replacing or extending its rendered parts:

Block Purpose
<:header> Replaces the month caption and previous/next controls.
<:weekday> Replaces each localized weekday label.
<:day> Replaces the contents of each day button.
<:footer> Adds content below the month grid, such as presets or selection help.

The default rendering is available when a block is omitted. See Custom rendering for the values yielded to each block.

Controlled

Pass @value and @onChange to own the selection yourself. Passing @value at all — including as undefined — puts selection in controlled mode; omit it entirely for uncontrolled use as in the demo above.

Selected: Fri Sep 18 2026

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class ControlledExample extends Component {
  @tracked value: Date | null = new Date();

  get label(): string {
    return this.value
      ? `Selected: ${this.value.toDateString()}`
      : 'No date selected';
  }

  handleChange = (value: Date | null): void => {
    this.value = value;
  };

  <template>
    <p class='mb-2 text-body-sm text-neutral'>{{this.label}}</p>
    <Calendar @value={{this.value}} @onChange={{this.handleChange}} />
  </template>
}

Range

Set @mode="range" to select a start and end day; the first click sets the anchor and the second commits the range. @visibleMonths={{2}} shows two months side by side, which is the usual pairing for a range picker.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

<template><Calendar @mode='range' @visibleMonths={{2}} /></template>

A controlled range @value whose end is null is a half-open, mid-interaction state — Calendar won't paint a range from it. Pass a full { start, end } object once both ends are chosen.

@showOutsideDays defaults to true with one visible month and false once @visibleMonths is greater than one, where a boundary date would otherwise appear in both grids. Passing an explicit value always wins, in either direction.

@pageBehavior controls how far the previous/next buttons move: 'visible' (the default) pages by the whole window, 'single' always by one month. @fixedWeeks renders six week rows in every month, so the calendar's height doesn't change as you page.

Min and max

@minValue and @maxValue bound which days are selectable. Navigation is bounded too: the previous/next buttons disable once paging would land entirely outside the bounds, and the month and year pickers offer only the months and years the bounds allow. @isDateUnavailable does not affect navigation — you can still page to a month whose every day is unavailable.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

const septemberFirst = new Date(2026, 8, 1);
const minValue = new Date(2026, 8, 5);
const maxValue = new Date(2026, 8, 20);

<template>
  <Calendar
    @defaultMonth={{septemberFirst}}
    @minValue={{minValue}}
    @maxValue={{maxValue}}
  />
</template>

Unavailable dates

@isDateUnavailable marks specific days as present but not selectable — a holiday, a booked night — shown struck through rather than dimmed. It's distinct from @minValue/ @maxValue: an unavailable day is still in range, just not choosable.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

function isWeekend(date: Date): boolean {
  const day = date.getDay();
  return day === 0 || day === 6;
}

<template><Calendar @isDateUnavailable={{isWeekend}} /></template>

In range mode, an unavailable date also blocks any range from being drawn across it — once one endpoint is chosen, days on the far side of an unavailable day become unreachable.

Three kinds of day look muted, and they don't all behave the same way:

Day Looks Selectable
Outside @minValue/@maxValue Dimmed No
Matched by @isDateUnavailable Struck through No
Belonging to a neighbouring month Dimmed Yes — selecting it pages the calendar to that month

Month and year dropdowns

@captionLayout="dropdown" replaces the plain month/year caption with a native month <select> and a year trigger that opens a year-grid picker.

September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

<template><Calendar @captionLayout='dropdown' /></template>

Colors and sizes

@color sets the color of the selected day and the range band; @size scales the cells and caption together.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import { array } from '@ember/helper';

const today = new Date();

<template>
  <div class='flex flex-wrap gap-6'>
    {{#each (array 'primary' 'success' 'danger') as |intent|}}
      <Calendar @color={{intent}} @size='sm' @defaultValue={{today}} />
    {{/each}}
  </div>
</template>
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import { array } from '@ember/helper';

const today = new Date();

<template>
  <div class='flex flex-wrap items-start gap-6'>
    {{#each (array 'sm' 'md' 'lg') as |size|}}
      <Calendar @size={{size}} @defaultValue={{today}} />
    {{/each}}
  </div>
</template>

Custom weekday labels

The <:weekday> block replaces the column headers, receiving short, long, narrow, and the weekday index. Pairing the narrow label with a Tooltip carrying the long one keeps the columns tight without losing the full weekday name.

September 2026
September 2026
S M T W T F S
import { Calendar } from 'frontile/collections';
import { Tooltip } from 'frontile';

<template>
  <Calendar>
    <:weekday as |weekday|>
      <Tooltip @content={{weekday.long}} as |t|>
        <span {{t.trigger}}>{{weekday.narrow}}</span>
      </Tooltip>
    </:weekday>
  </Calendar>
</template>

Presets

The <:footer> block renders below the grid. Combine it with @month/@onMonthChange and @value/@onChange to drive the calendar from preset buttons.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import { Button } from 'frontile';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';

function addDays(date: Date, amount: number): Date {
  const result = new Date(date);
  result.setDate(result.getDate() + amount);
  result.setHours(0, 0, 0, 0);
  return result;
}

export default class PresetsExample extends Component {
  @tracked month = addDays(new Date(), 0);
  @tracked value: Date | null = null;

  applyPreset = (offset: number): void => {
    const date = addDays(new Date(), offset);
    this.month = date;
    this.value = date;
  };

  handleMonthChange = (month: Date): void => {
    this.month = month;
  };

  handleChange = (value: Date | null): void => {
    this.value = value;
  };

  <template>
    <Calendar
      @month={{this.month}}
      @onMonthChange={{this.handleMonthChange}}
      @value={{this.value}}
      @onChange={{this.handleChange}}
    >
      <:footer>
        <div class='flex gap-2 pt-2'>
          <Button
            @size='sm'
            @variant='outline'
            {{on 'click' (fn this.applyPreset 0)}}
          >
            Today
          </Button>
          <Button
            @size='sm'
            @variant='outline'
            {{on 'click' (fn this.applyPreset 1)}}
          >
            Tomorrow
          </Button>
          <Button
            @size='sm'
            @variant='outline'
            {{on 'click' (fn this.applyPreset 7)}}
          >
            In a week
          </Button>
        </div>
      </:footer>
    </Calendar>
  </template>
}

Custom day content

The <:day> block replaces a day cell's content, receiving the day's state (selection, availability, focus, and more). It renders inside the cell's content wrapper, so a numeral plus a second line stacks and centres without you rebuilding that layout.

Two CSS variables size the cell around it. --calendar-cell-radius shapes it — a day is a circle by default, which crops anything wider than a numeral, so content like a price wants a smaller radius. --calendar-cell-size scales the whole grid, and content with a second line needs the extra room.

Let custom content inherit its color rather than setting a fixed one. A selected day swaps its text to the contrast color for the current @color, and anything inside it inherits that automatically — so opacity-70 gives you a muted second line that stays readable on both the resting surface and the selected fill. A fixed color like text-neutral looks right until the day is selected, then sits grey on a saturated background.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import { get } from '@ember/helper';

const prices: Record<number, number> = {
  5: 120,
  6: 120,
  12: 95,
  13: 95,
  19: 140,
  20: 140
};
const septemberFirst = new Date(2026, 8, 1);

<template>
  <Calendar
    @defaultMonth={{septemberFirst}}
    style='--calendar-cell-radius: var(--radius-lg); --calendar-cell-size: 3.5rem'
  >
    <:day as |day|>
      <span class='text-body-sm'>{{day.dayOfMonth}}</span>
      {{#if (get prices day.dayOfMonth)}}
        <span class='text-caption-sm opacity-70'>
          ${{get prices day.dayOfMonth}}
        </span>
      {{/if}}
    </:day>
  </Calendar>
</template>

Custom header

The <:header> block replaces the month caption and navigation entirely. It receives month, title, goToPrevious, goToNext, canGoPrevious, canGoNext, setMonth, setYear, isYearGridOpen, and toggleYearGrid — the same context the default header uses, so nothing about paging or bounds has to be reimplemented.

Nothing is rendered for you, including the year-grid trigger: call toggleYearGrid from your own control to keep it, as below, or leave it out for a calendar with no year picker.

September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';
import { Button } from 'frontile';
import { on } from '@ember/modifier';

<template>
  <Calendar>
    <:header as |header|>
      <div class='flex items-center justify-between gap-2 pb-2'>
        <Button
          @size='sm'
          @variant='outline'
          disabled={{unless header.canGoPrevious true false}}
          {{on 'click' header.goToPrevious}}
        >
          Back
        </Button>

        <Button
          @size='sm'
          @variant='plain'
          aria-expanded={{if header.isYearGridOpen 'true' 'false'}}
          {{on 'click' header.toggleYearGrid}}
        >
          {{header.title}}
        </Button>

        <Button
          @size='sm'
          @variant='outline'
          disabled={{unless header.canGoNext true false}}
          {{on 'click' header.goToNext}}
        >
          Next
        </Button>
      </div>
    </:header>
  </Calendar>
</template>

Localization

@locale is a BCP-47 language tag ("nl-NL", "ja-JP") — Calendar formats months, weekdays, and captions through Intl.DateTimeFormat, not a date-fns Locale object. @weekStartsOn overrides the first day of the week the locale would otherwise imply.

september 2026
september 2026
ma di wo do vr za zo
import { Calendar } from 'frontile/collections';

<template><Calendar @locale='nl-NL' @weekStartsOn={{1}} /></template>

Disabled and read-only

@isDisabled blocks navigation and selection entirely. @isReadOnly still allows paging between months but blocks selecting a day.

September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
September 2026
September 2026
Sun Mon Tue Wed Thu Fri Sat
import { Calendar } from 'frontile/collections';

const today = new Date();

<template>
  <div class='flex flex-wrap gap-6'>
    <Calendar @isDisabled={{true}} @visibleMonths={{1}} />
    <Calendar @isReadOnly={{true}} @defaultValue={{today}} />
  </div>
</template>

Accessibility

The day grid is a single tab stop: Tab moves focus onto the currently focused day, and arrow keys move within the grid without adding extra stops. Moving past the edge of a visible month pages the calendar to bring the new day into view.

Key Action
/ Move focus one day back / forward
/ Move focus one week back / forward
Home / End Move to the start / end of the current week
Page Up / Page Down Move back / forward one month
Shift + Page Up / Shift + Page Down Move back / forward one year
Enter / Space Select the focused day
Escape Cancel a pending range selection

Escape works no matter which control inside the calendar has focus — a day cell, the Previous/Next buttons, or the month <select> — so a pending range can always be canceled without first tabbing back into the grid.

In range mode the first click emits a half-open { start, end: null }, and Escape emits null to retract it — so a controlled @value is cleared rather than left holding a start date.

Each month grid has role="grid" with an accessible label naming the month and year, and day cells use role="gridcell" with aria-selected. Each day button also carries a full aria-label (weekday, month, day, and year) built from Intl.DateTimeFormat, so crossing a month boundary with the arrow keys announces the complete new date rather than a bare day-of-month number. The month caption is also announced through a visually hidden live region when navigation changes it, so month changes reach screen reader users even though focus stays on the grid. @autofocus moves DOM focus into the grid on insert and only then; rendering a calendar otherwise never moves focus. @isReadOnly marks each grid aria-readonly="true" so assistive technology knows the days are inert.

API

Calendar

Element: HTMLDivElement

Arguments

Name Type Default Description
autofocus boolean false Moves DOM focus into the grid on insert. This is the only thing that may focus the calendar on mount -- rendering a calendar must never otherwise steal focus.
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' | 'body' | 'footer' | 'header' | 'nav' | 'title' | 'cell' | 'indicator' | 'monthSelectWrapper' | 'monthSelectValue' | 'monthSelect' | 'monthSelectIcon' | 'yearTrigger' | ... 10 more ... | 'yearCell'> - Overrides the classes applied to individual slots.
color enum 'primary' The color used for the selected day and the range band.
defaultMonth Object - Seeds the visible month when uncontrolled -- the first month of the window when @visibleMonths is greater than one.
defaultValue CalendarValue<M> - Seeds the selection when uncontrolled.
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.
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 Blocks navigation and selection entirely.
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.
labelledBy string -

The id of an element naming this calendar, forwarded to each month grid's aria-labelledby. Use it when something outside the calendar already names it -- a date picker's own label, say -- so the grid is announced with that name instead of its month caption.

An aria-labelledby passed through ...attributes lands on the root element, which has no role, so it would not reach the grid.

locale string - BCP-47 tag. All human-readable text is produced by Intl from this.
maxValue Object - Latest selectable date. Also clamps month navigation.
minValue Object - Earliest selectable date. Also clamps month navigation.
mode Array 'single' Whether a click selects one day or the two ends of a range.
month Object -

Controlled visible month -- the first month of the window when @visibleMonths is greater than one.

Passing this argument at all puts the month axis in controlled mode -- passing it as undefined included. Omit it entirely to let Calendar track the visible month itself.

onChange function - Called with the new selection when the user picks or clears a day.
onMonthChange function - Called with the month the user asked to move to.
pageBehavior enum 'visible' How far prev/next paging advances. 'visible' moves by the whole window (@visibleMonths); 'single' always moves by one month.
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.
size enum 'md' The size of the calendar cells and caption text.
value CalendarValue<M> - Controlled selection. Passing this argument at all puts selection in controlled mode -- passing it as undefined included.
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
day * Array -
header * Array -
weekday * Array -
footer * Array -
Released under MIT License - Created by Josemar Luedke