Calendar
The <x-wirekit:: 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
| 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.
| 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/, 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.
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
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/ under components..
| 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. 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 translated method from Carbon respects your config('app. 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. 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.
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.
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") inabbr, 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 viaaria-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 failsaria-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 havetabindex="-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):
disabledattribute prevents interaction selectable: nativeHeader <select>controls (full keyboard + screen-reader support) withsr-onlylabels and anaria-livemonth announcement- Multi-month (
months > 1): each month is its ownrole="grid"; arrow keys move between the displayed grids, keeping a single rovingtabindex="0" - An
aria-labeloraria-labelledbyof yours makes the calendar agroupnamed 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::. The calendar primitive is for displaying a month view — not collecting input.date-picker> -
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-contentbox 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:::
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
- WAI-ARIA Date Picker Dialog Example — the reference implementation this component follows
- WAI-ARIA Grid Pattern — keyboard navigation pattern used for the day grid
- MDN:
role="grid" - MDN:
aria-selected