Custom Datepicker with Alpine.js: Build a Calendar Without Flatpickr
AI generated
x-data
Alpine
Alpine.js · Datepicker · Calendar · Accessibility
Custom Datepicker with Alpine.js
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.

15 min read x-data · x-for · @keydown · ARIA · calendar grid Alpine.js 3.x · Hyva Themes

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 };
},

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.

11. FAQ: Alpine.js datepicker without Flatpickr

1Can I use the Alpine.js datepicker in Hyva Themes?
Yes. Alpine.js is the standard JS layer in Hyva. Register it as Alpine.data, load it through the Hyva module system, and use registerInlineScript for CSP compliance.
2How do I compute the calendar grid?
Find the first weekday, fill previous month gaps, add the current month, complete the last week. Split into groups of seven for x-for iteration over weeks and days.
3How do I sync the datepicker and a form field?
A visible text field with an inputValue getter for local display. A hidden input with isoValue for form submission. Both are bound to Alpine state via :value, staying automatically in sync.
4How do I implement keyboard navigation?
Roving tabindex: only the focused day has tabindex=0. @keydown.arrow-* moves focus, $nextTick sets DOM focus. Enter selects, Escape closes. Standard ARIA grid pattern.
5inputValue vs. isoValue: what is the difference?
inputValue is the localized display format (05/15/2026). isoValue is the machine readable ISO 8601 string (2026-05-15) for server processing and form submission.
6How do I prevent selecting disabled days?
The isDisabled flag in makeDay checks against minDate/maxDate. selectDay: if (day.isDisabled) return; In the template: :disabled and :aria-disabled="day.isDisabled".
7How do I use Alpine.data for a reusable datepicker?
Register Alpine.data('datepicker', (options = {}) => ({...})) inside alpine:init. In the template: x-data="datepicker({ min: '2026-01-01' })". Configurable and reusable across multiple instances.
8Do I also need server side validation?
Yes, always. Alpine validation is UX, not a security feature. Hidden input values can be manipulated. The Magento controller must validate min/max limits and format independently.
9How do I handle time zones?
new Date(year, month, day) creates a local date. For pure date fields, build isoValue as local formatting (YYYY-MM-DD) instead of using toISOString(), which returns UTC.
10Can the datepicker also work as an inline calendar?
Yes. Drop the open state and trigger input, render the calendar directly in the DOM. The grid algorithm and selection logic stay identical, only the dropdown behavior goes away.