Skip to main content
Copy for LLM

Calendar

The <x-wirekit::calendar> component renders a standalone month grid for single-date selection. It supports full keyboard navigation (arrow keys, Page Up/Down, Home/End) and integrates with forms via a hidden input. The implementation follows the WAI-ARIA Date Picker Dialog pattern.

Usage

Calendar
Mon Tue Wed Thu Fri Sat Sun

Multi-month & quick navigation

Render several months side by side with months, or swap the static month label for native month + year jump selects with selectable-header. Both are opt-in, so the default single-month calendar is unchanged.

Quick month / year navigation
Mon Tue Wed Thu Fri Sat Sun

Date range

Add the range flag and the calendar takes two clicks: the first sets the start, the second the end. A second click before the start simply becomes the start — there is no rule to learn about which end to pick first. While only the start is set, the day under the pointer stands in for the end, so the shading answers the question a reader is actually asking: what am I about to choose.

value reads and writes YYYY-MM-DD/YYYY-MM-DD, the same spelling date-picker uses with its own range flag — a value moves between the two without translation. The form receives the two ends as name[start] and name[end], the same two fields a range date-picker sends, and wire:model receives the combined value. There is no combined name field beside them: PHP keeps only one of a string and an array sent under the same name, so a handler could never read both.

Date range over two months
Two months side by side

In a multi-month view the day grids stay fully keyboard-navigable: arrow keys cross between the displayed grids before shifting the whole window.

Week start

Weeks begin on Monday by default. Pass week-starts-on to change it — 0 for a Sunday-first week, 1 for Monday. The app-wide default lives in config/wirekit.php under components.calendar.week-starts-on.

Sunday-first week (week-starts-on=0)
Sun Mon Tue Wed Thu Fri Sat

Pre-selected Date

Pass an ISO 8601 date string (YYYY-MM-DD) to the value prop. The calendar opens on that date's month with the day highlighted. A date object works too, such as a model attribute with a date cast: the calendar reads the day it names. In range mode, the two ends can also arrive as an array, ['start' => $from, 'end' => $to], each a date object or a string.

<x-wirekit::calendar name="start_date" value="2026-01-10" />

No Initial Value

Without a value, the calendar defaults to the current month with today's date visually highlighted (ring indicator, not selected).

<x-wirekit::calendar name="appointment" />

Inside a Form

The calendar renders a hidden <input> with the given name attribute (two in range mode, name[start] and name[end]). When a date is selected, the hidden input's value updates and dispatches an input event — making it compatible with standard form submission and wire:model.

<form wire:submit="save">
    <x-wirekit::field label="Start Date">
        <x-wirekit::calendar name="start_date" :value="$startDate" wire:model="startDate" />
    </x-wirekit::field>

    <x-wirekit::button type="submit">Save</x-wirekit::button>
</form>

With Livewire

The hidden input dispatches a native input event on selection, so wire:model bindings work automatically. Pass the bound property as :value as well: the calendar keeps the chosen date in its own state, seeded from value, so without it a stored date is not shown and a date the server sets does not reach the grid.

<x-wirekit::calendar name="event_date" :value="$eventDate" wire:model.live="eventDate" />

Visual States

State Visual Treatment
Today Bold weight + accent ring outline (when not selected)
Selected Accent background with accent foreground text
Current month day Normal text, hover shows subtle background
Adjacent month day Muted text with reduced opacity, not clickable

Keyboard

The calendar grid supports full keyboard navigation following the WAI-ARIA Grid pattern:

Key Action
Tab Move focus into the date grid
Arrow Right Move focus to the next day
Arrow Left Move focus to the previous day
Arrow Down Move focus to the same day next week
Arrow Up Move focus to the same day previous week
Page Down Move to the next month
Page Up Move to the previous month
Home Move focus to the first day of the month
End Move focus to the last day of the month
Enter / Space Select the focused date

When arrow keys move past the first or last day of the month, the calendar navigates to the previous or next month automatically.

The arrow keys go on from the day that has the focus, and that includes a day you clicked: after a click on the 22nd, Arrow Right moves to the 23rd.

Date Format

The calendar uses ISO 8601 (YYYY-MM-DD) format internally and in the hidden input value. Everything a reader sees or hears — the month/year header, the weekday column headers, the month names in the jump <select>, and the full date each day cell is named with — is formatted in your application's locale, the one config('app.locale') carries.

Displaying another language

Set the application locale and the calendar follows it; there is nothing to configure on the component:

// Anywhere before the view renders — middleware, a controller, a Livewire mount.
App::setLocale('de');

The header then reads "April 2026", "Dezember 2026", "März 2026", and a day cell is announced as "Mittwoch, 15. April 2026". The word for today, which no date formatter produces, comes from WireKit's own translation files.

A calendar mounted by hand — a factory call outside the Blade component — receives no locale and falls back to the browser's preference rather than to English.

The hidden input always uses ISO 8601 (YYYY-MM-DD) regardless of the display locale. If your backend needs a different format (like DD.MM.YYYY), convert on the server side:

// In your Livewire component or controller
$formatted = Carbon::parse($this->startDate)->format('d.m.Y');     // 15.04.2026
$written   = Carbon::parse($this->startDate)->translatedFormat('d. F Y'); // 15. April 2026

The translatedFormat() method from Carbon respects your config('app.locale') setting, so with 'locale' => 'de' it outputs German month names automatically.

Optimistic UI

Pass the name of the Livewire method the calendar should call and the date lands immediately, then confirms or undoes itself when the server answers:

<x-wirekit::calendar
    name="due"
    :value="$due"
    optimistic="saveDue"
/>

Load wirekit-optimistic.js alongside whichever bundle you already use — below it, in your layout:

@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}" defer></script>

Try it

The demo below runs the real path: the change shows immediately, the outline says it is provisional, and the server's answer either confirms it silently or takes it back.

Optimistic calendar — accept it, refuse it, watch a slow answer

Accepted

Mon Tue Wed Thu Fri Sat Sun

The date is kept and nothing is said.

Refused

Mon Tue Wed Thu Fri Sat Sun

The old date comes back, and the refusal is spoken.

Slow to answer

Mon Tue Wed Thu Fri Sat Sun

The dashed outline is the provisional state.

The <livewire:demos.…> wrapper above exists only on this site — it supplies the demo methods so the page can show a real round trip. The block under it is what you write.

The prop takes a method name rather than true because the component cannot know the action otherwise: server actions reach a WireKit component through the attribute bag, so it never sees your wire:click. optimistic replaces wire:model.live here rather than joining it — the method you name is what changes the value on the server; pass the current one with :value so the calendar knows where it started.

The displayed month does not roll back with the date. If you paged forward to March and the pick was refused, you are still looking at March — only the selection returns to what the server has. Where you are looking is yours; the value is the server's.

What a screen reader hears

Picking a date announces once, hedged — "Saving" — so the new value is audible as provisional. Confirmation is silent: what was announced is what happened. Only a deviation speaks a second time, which is what makes an undo recognizable as an undo.

Where the calendar sits in a form that already shows a validation message, the undo stays silent and leaves that message to speak: it tells you what to do, "could not save" does not.

An aborted request announces nothing at all — nothing was refused.

Focus stays exactly where you put it. An undo arrives on the server's schedule, and moving focus then would take you out of your place for a reason you could not predict.

Props

Prop Type Default Description
value string|DateTimeInterface|array|null null Initial selected date in YYYY-MM-DD format, or a date object; in range mode also the two ends as an array
optimistic string|null null Livewire method to call, showing the new date before the server confirms it. See Optimistic UI.
optimisticArgs array [] Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to.
name string 'date' Name attribute for the hidden form input (read from $attributes). The ids of the month and year selects and of each grid's heading derive from it; a second calendar with the same name on the page, or a second without one, gets -2, so each grid is named by its own month
months int 1 Number of consecutive months to display side by side (clamped 1–4)
range bool false Two-click range selection. value reads and writes YYYY-MM-DD/YYYY-MM-DD, and the form receives name[start] and name[end] in place of name
selectableHeader bool false Replace the static month label with native month + year jump selects
required bool false Marks the date as required: the month grids carry aria-required="true". In a form, an empty required calendar stops the submit as a native required field does: the browser's own message shows under the months, in the reader's language, and the focus moves to the day the keyboard stands on when it is the first empty field.
weekStartsOn int 1 First day of the week — 0 (Sunday) or 1 (Monday, default). App-wide default in config/wirekit.php
scope string|null null Scoped personalization key

Accessibility

  • Grid: role="grid" on the <table> element
  • Cells: role="gridcell" on each <td>
  • Rows: role="row" on each <tr>
  • Day headers: <th scope="col"> for column headers, showing the short weekday ("Mon") and carrying the full one ("Monday") in abbr, both in the application's locale already in the HTML the server sends, so a page read before its script runs shows them in that language too
  • Day cells: each day button is named with its whole date — "Wednesday, April 15, 2026" — because its visible text is the number alone, and today says so
  • Grid name: each <table role="grid"> is labeled by its own month heading via aria-labelledby
  • Selected day: aria-selected="true" on the <td role="gridcell">, which is where the ARIA grid pattern allows it — putting it on the button inside fails aria-allowed-attr
  • Under forced colors, where the accent fill of a chosen day and the tint of a range are not painted, a chosen day and either end of a range are framed in the system's selection color, and the days between them carry a line above and below
  • Roving tabindex: only the focused day has tabindex="0"; all others have tabindex="-1". A click moves it to the day it lands on, so a reader who tabs away and back returns to that day
  • Month label: aria-live="polite" announces month changes to screen readers, in the application's locale
  • Nav buttons: aria-label="Previous month" / aria-label="Next month"
  • Chevron SVGs: aria-hidden="true" (decorative)
  • Disabled days (adjacent months): disabled attribute prevents interaction
  • selectableHeader: native <select> controls (full keyboard + screen-reader support) with sr-only labels and an aria-live month announcement
  • Multi-month (months > 1): each month is its own role="grid"; arrow keys move between the displayed grids, keeping a single roving tabindex="0"
  • An aria-label or aria-labelledby of yours makes the calendar a group named by it, and each month keeps its own name.

Day-cell size on touch

Day cells measure 36×36 and the month navigation buttons 28×28. Both clear the 24×24 minimum of WCAG 2.5.8 and both stay under the 44×44 of the AAA criterion 2.5.5 — a deliberate choice, not an oversight.

The reason is that the enlargement would defeat itself. In a seven-column grid at 36px pitch, a 44px hit area reaches 4px into both horizontal neighbors, and the cell painted last takes the taps meant for the other. A day that is slightly small is workable; a day that activates its neighbor is not. Reaching 44 here needs a bigger calendar, which is a layout decision — so if your app needs it, scale the component rather than only its hit areas.

Keyboard Interaction

See Keyboard above — the full key table lives there, and it is the complete one: it carries the month-wrap behavior at the grid edges that this section never had.

Pitfalls

  • Don't use calendar for date input. Use <x-wirekit::date-picker>. The calendar primitive is for displaying a month view — not collecting input.

  • It needs 222px, and below that the last column goes over the edge. A day is 36px wide where there is room and shrinks with its column, down to 24px, the smallest target WCAG 2.5.8 allows. So the grid stops at 196px, and with the panel's 12px padding either side plus its 1px border that makes 222px the narrowest container the calendar fits inside. Where a container sizes to its content, as a flex item or a fit-content box does, the calendar takes the 306px of its full-size days.

    What happens below 222px is worth knowing, because it is quiet. The grid keeps its 196px and starts overrunning the panel's padding; nothing scrolls and nothing throws. Around 210px the seventh column crosses the panel's outer edge, and in a container that does not scroll a reader simply loses Sunday.

    A phone leaves more than that even at 320px, which is also the width a desktop reader has at 400% zoom. It matters when the calendar sits in something much narrower: a sidebar or a split pane. Give it 222px or let its container scroll.

Design Tokens

Token Used for
--font-wk-sans Calendar font family
--font-wk-body-weight / --font-wk-heading-weight Day / month-title weight
--text-wk-xs / --text-wk-sm / --text-wk-md Weekday header / day / month-title font size
--color-wk-text Day-cell text
--color-wk-text-muted Out-of-month + weekday header text
--color-wk-accent Selected day background
--color-wk-accent-fg Selected day text
--color-wk-bg-elevated Calendar panel background
--color-wk-bg-hover Day and navigation-button hover background
--color-wk-border Panel border
--color-wk-border-strong / --color-wk-border-strong-hover Border of the month and year selects of selectable-header
--color-wk-ring Focus ring
--ring-wk-width Focus ring width
--border-wk-width Border width
--radius-wk-sm / --radius-wk-lg Day-cell + panel border radius
--shadow-wk-md Panel drop shadow
--padding-wk-x-md / --padding-wk-y-xs / --padding-wk-y-sm Cell + nav button padding
--transition-wk-duration Hover / selection transition

Personalization

Override classes globally via WireKit::personalize():

use Pushery\WireKit\WireKit;

WireKit::personalize('calendar', [
    // Keep `wk-calendar`. It is not decoration: a rule in `dist/wirekit.css` uses it
    // to flip the grid to `block; width: 100%` below 640px, and a base without it leaves
    // the calendar at its fixed ~312px content width on a phone — narrow enough to clip
    // the trailing weekday column in a padded container.
    'base' => 'wk-calendar inline-block p-[var(--padding-wk-x-md)]'
        .' bg-[var(--color-wk-bg-elevated)]'
        .' border-[length:var(--border-wk-width)] border-[var(--color-wk-border)]'
        .' rounded-[var(--radius-wk-lg)] shadow-[var(--shadow-wk-sm)]'
        .' font-[family-name:var(--font-wk-sans)]',
    'header' => 'flex items-center justify-between mb-2',
]);

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store