Skip to main content
WireKit
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
Mo Tu We Th Fr Sa Su

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
Mo Tu We Th Fr Sa Su

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 name[start] and name[end] alongside the combined name field, so a handler written for a single date still gets something it understands.

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)
Su Mo Tu We Th Fr Sa

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.

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

<x-wirekit::calendar name="event_date" 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
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.

Date Format

The calendar uses ISO 8601 (YYYY-MM-DD) format internally and in the hidden input value. The month/year header displays a localized English format (e.g., "April 2026") using Date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }).

European / German Format

The header locale is currently set to en-US. To display the month header in German (e.g., "April 2026" → "April 2026" in German, or "Dezember 2026" for December), you can override the monthLabel getter via Alpine:

<x-wirekit::calendar name="termin" value="2026-04-15"
    x-init="
        Object.defineProperty($data, 'monthLabel', {
            get() {
                const d = new Date(this.viewYear, this.viewMonth, 1);
                return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
            }
        });
    "
/>

This changes the month header to the German locale — "April 2026", "Dezember 2026", "März 2026", etc.

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') }}"></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

Mo Tu We Th Fr Sa Su

The date is kept and nothing is said.

Refused

Mo Tu We Th Fr Sa Su

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

Slow to answer

Mo Tu We Th Fr Sa Su

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|null null Initial selected date in YYYY-MM-DD format
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)
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 also receives name[start] and name[end]
selectableHeader bool false Replace the static month label with native month + year jump selects
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 (Su, Mo, Tu, etc.)
  • Selected day: aria-selected="true" on the active day button
  • Roving tabindex: only the focused day has tabindex="0"; all others have tabindex="-1"
  • Month label: aria-live="polite" announces month changes to screen readers
  • 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"

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

Key Action
Tab Move focus into the date grid
ArrowLeft / ArrowRight Move between days
ArrowUp / ArrowDown Move between weeks
PageUp / PageDown Move by month (or year with Shift)
Home / End Jump to start / end of the week
Enter / Space Select the focused date

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 306px, and below that the last column goes over the edge. Seven columns of day cells do not shrink indefinitely: the grid stops at 280px, and with the panel's 12px padding either side plus its 1px border that makes 306px the narrowest container the calendar fits inside cleanly. Measured in a browser across a sweep from 330px down to 200px, not derived from the stylesheet.

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

    On a 360px phone this is comfortable — a full-width block leaves well over 306px. It matters when the calendar sits in something narrower than the viewport: a preview frame, a sidebar, a split pane. Give it 306px 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-subtle Day hover background
--color-wk-border Panel border
--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', [
    'base' => 'inline-block bg-white border rounded-lg shadow p-4 font-sans',
    'header' => 'flex items-center justify-between mb-2',
]);

Further Reading

Was this page helpful?

Thanks — that helps.

Voting requires cookies or local storage. What we store