A Calendar Without Flatpickr, Built Entirely From Scratch
Flatpickr brings along kilobytes of jQuery heritage and an external dependency. A custom Alpine.js datepicker fits into fewer than 80 lines of reactive JavaScript, complete with month navigation, keyboard support, min/max limits, and full ARIA semantics, without a single external dependency.
Table of Contents
- 1. Why not use a ready made datepicker?
- 2. Core structure: x-data with date state
- 3. Calculating the calendar grid dynamically
- 4. Month and year navigation
- 5. Syncing date selection with the input field
- 6. Enforcing min and max limits
- 7. Keyboard navigation with @keydown
- 8. ARIA semantics for accessibility
- 9. Alpine datepicker vs. Flatpickr compared
- 10. Summary
- 11. FAQ
1. Why not use a ready made datepicker?
The first question developers ask when they have to build a datepicker themselves is: why not just pull in Flatpickr or Pikaday? The answer comes down to weight. Flatpickr adds roughly 40 KB of uncompressed JavaScript, ships its own state mechanism that operates independently from Alpine.js, and needs careful integration through x-init hooks to react to Alpine state changes at all. In a Hyva Themes context, where jQuery, Knockout.js, and external UI libraries are deliberately avoided, that runs against the project's own architectural decisions.
A self built Alpine.js datepicker has no separate state manager: it simply is Alpine state. That means no manual syncing, no event bridging, and no initialization logic that breaks under server side rendering or dynamically loaded forms. The JavaScript Date API has been powerful enough since ES2015 to compute a full calendar grid. What sounds like a complex task turns out, in practice, to fit into about 80 lines of reactive code.
There is another angle too: control over the markup. Flatpickr renders its own DOM and offers CSS classes for customization. A custom datepicker uses Tailwind classes directly, fits straight into the design system without risking conflicts, and can be extended freely with project specific features, such as integrating a Hyva form validator or connecting to a Magento REST endpoint for blocked delivery dates.
2. Core structure: x-data with date state
The state of a datepicker is fairly compact: the currently displayed year, the currently displayed month, the selected date, and a flag for whether the calendar is visible. All four values live in a single x-data object, alongside helper methods that turn these values into the calendar grid. Alpine.js keeps everything reactive: when the month changes, the getter recomputes the grid immediately.
Whether you place the logic directly inside the inline x-data object or register it as a proper Alpine component via Alpine.data() depends on how reusable it needs to be. If the datepicker is needed in several places, say a checkout form and a filter panel, it is worth defining Alpine.data('datepicker', () => ({...})) in a separate JS file that gets loaded through Hyva's module system. For a one off use, inline is enough and keeps the template self describing.
// Alpine.js datepicker: core state definition
// Register as reusable component in your Hyva JS init file
document.addEventListener('alpine:init', () => {
Alpine.data('datepicker', (options = {}) => ({
open: false,
selected: null, // Date object or null
viewYear: new Date().getFullYear(),
viewMonth: new Date().getMonth(), // 0-indexed
minDate: options.min ? new Date(options.min) : null,
maxDate: options.max ? new Date(options.max) : null,
// Computed: human-readable value for the input field
get inputValue() {
if (!this.selected) return '';
return this.selected.toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
},
// Computed: machine-readable ISO value for hidden input
get isoValue() {
if (!this.selected) return '';
return this.selected.toISOString().slice(0, 10);
},
toggle() { this.open = !this.open; },
close() { this.open = false; },
}));
});
3. Calculating the calendar grid dynamically
The calendar grid is an array of week objects, each made up of seven day objects. Every day carries information about whether it belongs to the current month, whether it is selected, whether it is disabled, and which Date object it represents. Alpine.js recomputes this array in a getter whenever viewYear or viewMonth changes, without any manual trigger.
The algorithm works like this: find the first day of the month and derive the week start from it. Fill in days from the previous month to complete the first week. Add all days of the current month. Fill the last week with days from the following month. Split the flat array into weeks of seven elements. The result is a two dimensional array that can be iterated directly with x-for, once over weeks and once over days within each week.
// Calendar grid computation: add this inside Alpine.data('datepicker', ...)
get weeks() {
const year = this.viewYear;
const month = this.viewMonth;
const firstDay = new Date(year, month, 1);
// Monday-first: Sunday (0) → position 6, Mon (1) → 0, etc.
const startOffset = (firstDay.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrevMonth = new Date(year, month, 0).getDate();
const days = [];
// Fill days from previous month
for (let i = startOffset - 1; i >= 0; i--) {
days.push(this.makeDay(year, month - 1, daysInPrevMonth - i, false));
}
// Fill current month
for (let d = 1; d <= daysInMonth; d++) {
days.push(this.makeDay(year, month, d, true));
}
// Fill next month to complete last week
const remaining = (7 - (days.length % 7)) % 7;
for (let d = 1; d <= remaining; d++) {
days.push(this.makeDay(year, month + 1, d, false));
}
// Split into weeks
const weeks = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return weeks;
},
makeDay(year, month, day, currentMonth) {
const date = new Date(year, month, day);
const isSelected = this.selected &&
date.toDateString() === this.selected.toDateString();
const isDisabled = (this.minDate && date < this.minDate) ||
(this.maxDate && date > this.maxDate);
const isToday = date.toDateString() === new Date().toDateString();
return { date, day, currentMonth, isSelected, isDisabled, isToday };
},
4. Month and year navigation
Navigating between months and years is the simplest method on the datepicker. Clicking the back arrow decrements viewMonth by 1; if the value then becomes -1, it is reset to 11 and viewYear is decremented by 1. Alpine.js propagates the change straight into the weeks getter, which computes the new grid. Because of Alpine's reactivity, the calendar's transition happens without a single manual DOM manipulation.
For year navigation, you can either offer arrows that step one year at a time or a dropdown covering a range of years. The dropdown pattern is more ergonomic for dates far in the past or future, such as a date of birth field. It is built with a <select> element whose value is bound to viewYear via x-model, with options generated by x-for from a computed range of years.
5. Syncing date selection with the input field
Clicking a day button sets this.selected to that day's Date object and closes the calendar dropdown. The input field immediately shows the formatted date through the inputValue getter. The hidden <input type="hidden"> holding the ISO value ensures that the machine readable value is sent on form submit, while the user sees a locally formatted date.
If you want to allow manual text entry, you need to parse and validate the text field changes. That happens in a parseInput method: it tries to interpret the typed string as a German style date (DD.MM.YYYY), checks whether the result is a valid date, and only sets this.selected if parsing and validation both succeed. Invalid input is communicated to the user through a visual error signal without compromising Alpine state consistency.
6. Enforcing min and max limits
Min and max date limits typically come from the Magento backend, for example as PHP generated data-min and data-max attributes on the container element. The Alpine.data component takes these values as an options parameter and converts them into Date objects in the constructor. The makeDay helper sets the isDisabled flag, and the template renders disabled days with reduced opacity and without a click handler.
Important: validation must also happen server side. The Alpine frontend protects the UX, but the browser side min/max logic is not a security feature; an attacker can manipulate the hidden input's value directly. The Magento controller must repeat the date validation. Alpine owns the UX layer, PHP owns the security layer.
// Full datepicker HTML template (abbreviated), use in Magento .phtml
// Assumes Alpine.data('datepicker') is registered
// Day selection and close: methods inside Alpine.data('datepicker', ...)
selectDay(day) {
if (day.isDisabled) return;
this.selected = day.date;
// If selected date is in prev/next month, navigate there
if (!day.currentMonth) {
this.viewYear = day.date.getFullYear();
this.viewMonth = day.date.getMonth();
}
this.close();
// Dispatch to parent Alpine component or Magento form listeners
this.$dispatch('date-selected', {
iso: this.isoValue,
display: this.inputValue,
});
},
prevMonth() {
if (this.viewMonth === 0) { this.viewMonth = 11; this.viewYear--; }
else { this.viewMonth--; }
},
nextMonth() {
if (this.viewMonth === 11) { this.viewMonth = 0; this.viewYear++; }
else { this.viewMonth++; }
},
get monthLabel() {
return new Date(this.viewYear, this.viewMonth, 1)
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
},
7. Keyboard navigation with @keydown
A datepicker that only works with a mouse fails any accessibility review. Keyboard navigation means: arrow keys move between days, Enter selects the focused day, Escape closes the dropdown, and Page Up/Page Down move between months. Alpine.js wires these up directly to methods with @keydown.arrow-left, @keydown.arrow-right, @keydown.enter, and @keydown.escape.
The focused day is held in state as focusedDate. Every day button gets a :tabindex binding: only the focused day has tabindex="0", all others have tabindex="-1". When focus moves via the arrow keys, a $nextTick callback sets the DOM focus onto the new button. This pattern is called roving tabindex and is the ARIA standard for grid widgets like calendars.
8. ARIA semantics for accessibility
The calendar container gets role="dialog" and aria-modal="true". The trigger input field gets aria-haspopup="dialog" and :aria-expanded="open". Every day button gets :aria-label with the fully spelled out date, :aria-selected for the selected day, and :aria-disabled for disabled days. The month header gets aria-live="polite" so screen readers announce the new month name when navigating.
Focus trapping is another important aspect: while the calendar is open, tab navigation should stay inside the calendar. This can be achieved with a @keydown.tab handler that checks whether focus is about to leave the last focusable element inside the dialog, and if so, moves it back to the first element. Alpine.js makes this accessible via this.$el.querySelectorAll('[tabindex="0"]').
9. Alpine datepicker vs. Flatpickr compared
Building a datepicker yourself is not always the right call. The table below helps weigh the tradeoffs.
| Criterion | Flatpickr | Custom built with Alpine.js | Recommendation |
|---|---|---|---|
| Bundle size | ~40 KB JS + CSS | ~3 KB inline | Alpine when optimizing weight |
| Alpine integration | x-init bridge required | Native Alpine state | Alpine, no bridging needed |
| Styling | Override CSS classes | Tailwind directly | Alpine, no CSS conflicts |
| Date range picker | Built in | Build it yourself | Flatpickr when range support is needed |
| Maintenance | External dependency | Full control | Alpine, no upstream risk |
Flatpickr remains the better choice for complex requirements such as date range selection with hover preview, combined time pickers, or locale heavy applications with RTL support. For simple date selection in a Hyva Magento project without external dependencies, the self built Alpine datepicker is the cleaner solution.
Mironsoft
Alpine.js, Hyva Themes and Magento 2 frontend development
Need Alpine.js components for your Magento project?
We build performant, accessible Alpine.js components for Hyva Themes: datepickers, dropdowns, forms, and checkout flows, without external dependencies, fully integrated into the Tailwind design system.
Component development
Datepickers, dropdowns, tabs, and modals as Alpine.data components
Accessibility
ARIA compliant implementation, keyboard navigation, and screen reader testing
Hyva integration
Clean integration into the Hyva module system with CSP compliant inline scripts
10. Summary
A complete Alpine.js datepicker without an external library can be built in around 80 lines of JavaScript and a manageable HTML template. The state lives natively in Alpine, reactivity comes for free, and the markup uses Tailwind classes directly with no risk of CSS conflicts from external libraries. The key concepts are: x-data with getters for the calendar grid and formatted values, x-for for iterating over weeks and days, @keydown for keyboard navigation, and ARIA attributes for accessibility.
Whether to use Flatpickr or not depends on your requirements. Range pickers, time selection, and RTL support all favor Flatpickr. Weight, Alpine integration, Tailwind compatibility, and full markup control favor the self built approach. In a Hyva Themes context, with its explicit move away from external UI libraries, the Alpine datepicker is, in most cases, the architecturally more consistent solution.
Alpine.js datepicker: the essentials at a glance
State design
viewYear, viewMonth, selected, and open in a single x-data object. Calendar grid as a getter, automatically recomputed on month change.
Grid algorithm
Determine the first weekday, fill previous month gaps, add the current month, complete the last week. Split into groups of seven for x-for.
Keyboard & ARIA
Roving tabindex for grid navigation. @keydown.arrow-* for day focus, Escape to close. role="dialog", aria-selected, and aria-disabled.
Min/max limits
Options parameter in the Alpine.data constructor. isDisabled flag inside the makeDay helper. Server side validation is always additionally required.