Calendar
A controlled, Material-independent single-date calendar with keyboard and touch interaction.
RemixCalendar displays a Gregorian month and requests a selected date. The
application owns the selected value; the calendar owns its focused day and
displayed month. Use it inline, or use Date Picker for
a button-triggered overlay.
Basic usage
import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';
class AppointmentCalendar extends StatefulWidget {
const AppointmentCalendar({super.key});
@override
State<AppointmentCalendar> createState() => _AppointmentCalendarState();
}
class _AppointmentCalendarState extends State<AppointmentCalendar> {
DateTime? selected;
@override
Widget build(BuildContext context) => RemixCalendar(
value: selected,
firstDate: DateTime(2026),
lastDate: DateTime(2028, 12, 31),
semanticLabel: 'Appointment date',
isDateUnavailable: (date) => date.weekday == DateTime.sunday,
onChanged: (date) => setState(() => selected = date),
);
}The default month/year selectors require an Overlay ancestor. A routed
WidgetsApp supplies one; use Overlay.wrap in a builder-only host. No Material
theme or MaterialLocalizations is required.
Date and state contract
- Dates are year/month/day fields, not instants. Only the fields are compared:
input times and UTC flags are ignored and nothing is converted between
timezones, so the flag never changes which day two values name. The fields
themselves depend on how you made the value.
DateTime.now().toUtc()supplies UTC fields, which can be the day before or after the local date, so passDateTime.now()for today. Callbacks return local date values where those fields are representable. A historically skipped local day uses a UTC date-only fallback to preserve its fields. Treat both as civil dates; do not convert timezones to preserve a birthday or booking date. - Build dates with
civilDate(year, month, day), exported byremixandnaked_ui, rather than hand-buildingDateTime.utc(year, month, day). It returns the ordinary local date wherever the device's zone has that day, so it matches the dates the calendar returns, and the UTC carrier only for a day the zone skipped (such as Pacific/Apia's 2011-12-30), which a localDateTime(year, month, day)would change before the calendar receives it. - Compare dates with
isSameCivilDate(a, b)andcompareCivilDates(a, b), also exported byremixandnaked_ui; they read only year, month and day.==against your ownDateTime(year, month, day),toLocal(),millisecondsSinceEpochanddifference(...).inDaystreat the values as instants and can disagree for the skipped-day UTC carrier.isDateUnavailablereceives the same civil values. Persist a selected date as its fields, for example an ISOyyyy-MM-ddstring. firstDateandlastDateare inclusive. Unavailable in-range dates remain discoverable but cannot be selected.- Arrow navigation and changing the month do not call
onChanged. Selection stays unchanged if the parent does not accept a callback. enabled: falseor a nullonChangeddisables interaction. There is no separate read-only mode or form-validation engine.- Clearing the parent's value to null preserves the browsing month. A new non-null value navigates to its clamped month. An externally invalid value is not silently changed or cleared.
currentDateoverrides today (styled withonToday) and fallback focus, useful for deterministic examples/tests.
Styling
Remix supplies anatomy, not a theme. Style the calendar and its existing child controls independently:
import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';
final calendarStyle = CalendarStyler()
.weekday(TextStyler().fontSize(12).color(const Color(0xFF525252)))
.day(
CalendarDayStyler()
.borderRadius(.circular(8))
.label(TextStyler().fontSize(14))
.onSelected(
CalendarDayStyler()
.color(const Color(0xFF171717))
.label(TextStyler().color(const Color(0xFFFFFFFF))),
),
);Besides the widget-state variants (onSelected, onHovered, onFocusVisible,
onDisabled), day styles have two calendar variants:
onTodaymarkscurrentDate(or today). Widget-state variants win on shared properties, so a selected today still looks selected.onUnavailablemarks in-range dates thatisDateUnavailablerejects. They are disabled too, so declare it afteronDisabledto override it. Dates outsidefirstDate/lastDateare disabled but not unavailable.
import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';
final dayStyle = CalendarDayStyler()
.onToday(
CalendarDayStyler().border(.color(const Color(0xFF171717)).width(1)),
)
.onDisabled(
CalendarDayStyler().label(TextStyler().color(const Color(0xFFA3A3A3))),
)
.onUnavailable(
CalendarDayStyler().label(
TextStyler().decoration(TextDecoration.lineThrough),
),
);navigationStyle styles the previous/next icon buttons; selectorStyle styles
the month/year selects. headerBuilder can replace those controls using the
calendar state's bounded month-navigation actions. Day selection/focus behavior
remains owned by the calendar.
CalendarStyler.selectorSpacing sets the horizontal gap between month/year
selectors (default 8).
Without an explicit container width bound, the compact maximum is seven times
the resolved day minimum width plus horizontal container padding (308 by default).
A larger container minimum lifts that fallback. Set maxWidth to an explicit
finite size to replace the cap, or maxWidth(double.maxFinite) to fill a bounded
parent. Under an unbounded parent, double.maxFinite uses the compact fallback.
Narrower parents still allow horizontal scrolling without shrinking day targets.
Pass the styler to RemixCalendar(style: calendarStyle, ...), or call it directly:
calendarStyle(value: value, firstDate: firstDate, lastDate: lastDate, onChanged: onChanged).
CalendarStyler.call returns a RemixCalendar and accepts its constructor
parameters except style and styleSpec.
Localization and direction
Defaults are explicitly English; the widget does not detect date formatting or
week starts from the locale. Replace RemixCalendarLabels strings and callbacks
using the application's localization system. Its formatters cover month,
month/year, complete accessible date, weekday, day number, and year. The
month formatter receives an integer from 1 through 12. Use joinDayLabel
to join the complete date and contextual labels such as today with localized
punctuation.
firstDayOfWeek uses Dart's weekday numbers: Monday is 1, Sunday is 7. It defaults
to Sunday. Directionality controls RTL layout and horizontal keyboard movement;
it does not translate labels. Full accessible date names should include weekday,
day, month and year.
Interaction
| Input | Behavior |
|---|---|
| Tab / Shift+Tab | Enter or leave the day grid as one keyboard stop. |
| Left / Right | Move one visual day, respecting RTL. |
| Up / Down | Move one week. |
| Home / End | Move to the configured week's first/last day. |
| PageUp / PageDown | Move one month, clamping the day when necessary. |
| Shift+PageUp / Shift+PageDown | Move one year. |
| Enter / Space / click / tap | Request the focused or activated date. |
Hover and focus do not select. Touch does not require hover, swipe or dragging. The default day targets are 44 logical pixels; small spaces and enlarged text must retain access to every date through the component's scrollable layout.
The headless NakedCalendar and NakedCalendarDay own interaction. Calendar days
use supported Flutter button semantics with full date labels, selected/enabled
state and actions. This is not a claim of literal web ARIA-grid parity or
conformance on every assistive technology.
Vanilla recipe
In a Vanilla app, install calendar and import your generated UI barrel:
remix add calendarimport 'package:flutter/widgets.dart';
import 'ui/ui.dart';
Widget example() => UiCalendar(
value: null,
firstDate: DateTime(2026),
lastDate: DateTime(2028, 12, 31),
onChanged: (value) { /* Store the requested value in application state. */ },
);Ui is the application's chosen prefix. Wrap the host Navigator in UiTheme.
The hand-written facade supplies theme typography, Vanilla icon navigation and
Select selectors that grow with text. Day treatments cover today, selection,
hover, press, keyboard focus and disabled dates; unavailable dates add a strike
through, while out-of-bounds dates only fade. Override style, navigationStyle
and selectorStyle last. CalendarSpec has no generator target, so this
composite deliberately has no generated part.
Constructor
Constructor
import 'package:flutter/widgets.dart';
import 'package:remix/remix.dart';
RemixCalendar remixCalendarConstructor({
Key? key,
required DateTime? value,
required DateTime firstDate,
required DateTime lastDate,
ValueChanged<DateTime>? onChanged,
DateTime? currentDate,
bool Function(DateTime)? isDateUnavailable,
int firstDayOfWeek = DateTime.sunday,
bool enabled = true,
String? semanticLabel,
FocusNode? focusNode,
bool autofocus = false,
RemixCalendarLabels labels = const RemixCalendarLabels(),
Widget Function(BuildContext, NakedCalendarState)? headerBuilder,
IconButtonStyler navigationStyle = const IconButtonStyler.create(),
SelectStyler selectorStyle = const SelectStyler.create(),
CalendarStyler style = const CalendarStyler.create(),
CalendarSpec? styleSpec,
}) => throw UnimplementedError();Fortal widgets
In a Fortal app, remix add calendar installs <Prefix>Calendar through
the same UI barrel. It retains the Remix date, labels, focus and controlled
selection contract above. The hand-written facade merges caller child styles
last over the reviewed Fortal calendar, ghost navigation and Select recipes.
Day targets and chevrons scale with text. Today uses bold gray12 ink; range
drafts have a separate perimeter and a quieter previous selection. Nested
month/year panels have a bounded height and a persistent scroll thumb.
See Fortal for theme setup.
Properties
Widget Properties
value → DateTime?
Required. The parent-owned selected date, compared by year/month/day fields. Null shows no selection.
firstDate / lastDate → DateTime
Required. Inclusive bounds; times and UTC flags are ignored. firstDate must
be on or before lastDate.
onChanged → ValueChanged<DateTime>?
Optional. Requests a date. The calendar stays unchanged until the parent updates
value. Null disables the calendar.
currentDate → DateTime?
Optional. Today, for the onToday variant and fallback focus. Defaults to now.
isDateUnavailable → bool Function(DateTime)?
Optional. Marks in-range dates unselectable. They stay keyboard-focusable and
match onUnavailable.
firstDayOfWeek → int
Optional. First weekday column, Monday=1 through Sunday=7. Defaults to Sunday.
enabled → bool
Optional. Whether navigation, focus and selection are enabled.
semanticLabel → String?
Optional. Accessible name of the calendar group.
focusNode → FocusNode?
Optional. Focus entry point; requesting it focuses the current day.
autofocus → bool
Optional. Whether to focus the current day on mount.
labels → RemixCalendarLabels
Optional. Labels and date formatters; English by default.
headerBuilder → Widget Function(BuildContext, NakedCalendarState)?
Optional. Replaces the header's visuals and navigation. Day behavior stays owned by the calendar.
navigationStyle → IconButtonStyler
Optional. Previous/next button styling, resolved in each button's state.
selectorStyle → SelectStyler
Optional. Month/year selector styling.
style → CalendarStyler
Optional. Calendar and day styling.
styleSpec → CalendarSpec?
Optional. A raw resolved spec that bypasses style and inherited styles.
Style Methods
CalendarStyler styles the surface like a box (padding, color,
borderRadius, constraints, ...) and its parts through
weekday(TextStyler) and day(CalendarDayStyler). Use headerSpacing for the
gap below the header and weekdaySpacing for the gap before the first week.
Both default to zero; navigation centres follow the outer day columns.
CalendarDayStyler styles a day like a box, its number through
label(TextStyler), and its states through onSelected, onHovered,
onPressed, onFocusVisible, onDisabled, onToday and onUnavailable.
Use containerEffects(RemixBoxEffectsMix) for focus outlines and other box
effects: they follow the day tile's resolved radius, including range caps,
without changing its layout. NakedCalendarDayState.maybeOf(context) exposes
the day and its NakedRangePosition to context-aware style tokens.
For an inclusive date range, use Range Calendar instead. Typed/segmented entry, non-Gregorian calendars and timezone scheduling are not part of this component. The Vanilla recipe above packages these styles for installation.
HeroUI widgets
In a HeroUI app, remix add calendar installs <Prefix>Calendar
with the same controlled value, bounds, availability callbacks and labels as Remix.
The hand-written composition merges caller styles last for each independent part.
The recipes port HeroUI web 3.2.6 colors, typography and day geometry. Month/year Select menus replace the upstream year grid. Date pickers use a field-shaped button in place of segmented text entry. Day tiles and icons grow with text; range drafts take visual precedence over the previous committed range. Picker panels retain equal 12px insets. See Open Code for theme setup.