Date picker Components

Advanced date picker components with different options and features.

Installation

1. Stimulus Controller Setup

Start by adding the following controller to your project:

import { Controller } from "@hotwired/stimulus";
import { computePosition, offset, flip, shift, arrow, autoUpdate } from "@floating-ui/dom";
import AirDatepicker from "air-datepicker";
import localeEn from "air-datepicker/locale/en";

// AirDatepicker CSS is now imported directly.
// Ensure air-datepicker is added to your package.json and your build system handles these imports.

export default class extends Controller {
  static targets = ["input", "inlineCalendar"];
  static values = {
    placement: { type: String, default: "bottom-start" }, // Placement of the datepicker
    range: { type: Boolean, default: false }, // Whether to allow selecting a range of dates
    disabledDates: { type: Array, default: [] }, // Expects array of 'YYYY-MM-DD' strings
    timepicker: { type: Boolean, default: false },
    timeOnly: { type: Boolean, default: false }, // New value for time-only selection
    weekPicker: { type: Boolean, default: false }, // New value for week selection
    timeFormat: { type: String, default: "" }, // Default empty, logic will apply 'hh:mm AA' if timepicker is true and this is empty
    minHours: Number, // Undefined if not set, AirDatepicker uses its default
    maxHours: Number, // Undefined if not set
    minutesStep: Number, // Undefined if not set
    showTodayButton: { type: Boolean, default: false },
    showClearButton: { type: Boolean, default: false },
    showThisMonthButton: { type: Boolean, default: false },
    showThisYearButton: { type: Boolean, default: false },
    dateFormat: { type: String, default: "" }, // e.g., 'MM/dd/yyyy', AirDatepicker default if empty
    startView: { type: String, default: "days" }, // 'days', 'months', 'years'
    minView: { type: String, default: "days" }, // 'days', 'months', 'years'
    initialDate: { type: String, default: "" }, // 'YYYY-MM-DD' or JSON array of 'YYYY-MM-DD' for range
    minDate: { type: String, default: "" }, // 'YYYY-MM-DD'
    maxDate: { type: String, default: "" }, // 'YYYY-MM-DD'
    inline: { type: Boolean, default: false }, // Makes the calendar permanently visible
  };

  // CSS classes for preset buttons
  static PRESET_CLASSES = {
    active: [
      "bg-neutral-900",
      "text-white",
      "hover:!bg-neutral-700",
      "dark:bg-neutral-100",
      "dark:hover:!bg-neutral-200",
      "dark:text-neutral-900",
    ],
    inactive: ["text-neutral-700", "hover:bg-neutral-100", "dark:text-neutral-300", "dark:hover:bg-neutral-700/50"],
  };

  connect() {
    // Check if this element also has dropdown-popover controller
    // If so, don't initialize the datepicker (it's just used for preset methods)
    const hasDropdownController =
      this.element.hasAttribute("data-controller") &&
      this.element.getAttribute("data-controller").includes("dropdown-popover");

    if (!hasDropdownController) {
      this.initializeDatepicker();
      this.inputTarget.addEventListener("keydown", this.handleKeydown.bind(this));
    } else {
      // Listen for input click to detect active preset when dropdown opens
      this.inputTarget.addEventListener("click", () => {
        // Small delay to ensure dropdown is fully rendered
        setTimeout(() => {
          this._detectActivePreset();
        }, 50);
      });
    }
  }

  initializeDatepicker() {
    if (this.datepickerInstance) {
      this.datepickerInstance.destroy();
      // Ensure autoUpdate cleanup is robustly handled if destroy didn't trigger position's cleanup
      if (this.cleanupAutoUpdate) {
        this.cleanupAutoUpdate();
        this.cleanupAutoUpdate = null;
      }
      // Also clear the specific one for the current position call, if any
      if (this.currentPositionCleanupAutoUpdate) {
        this.currentPositionCleanupAutoUpdate();
        this.currentPositionCleanupAutoUpdate = null;
      }
    }

    const options = this._buildDatepickerOptions();

    if (!this.inlineValue) {
      options.position = this._createPositionFunction();
    }

    this.datepickerInstance = new AirDatepicker(this.inputTarget, options);

    // Format initial value for week picker
    if (this.weekPickerValue) {
      setTimeout(() => this._updateWeekDisplay(), 0);
    }

    // Trigger change for inline calendars
    if (this.inlineValue) {
      setTimeout(() => this._triggerChangeEvent(), 0);
    }
  }

  _buildDatepickerOptions() {
    const options = {
      locale: localeEn,
      autoClose: !this.inlineValue,
      inline: this.inlineValue,
      container: this.inputTarget.closest("dialog") || undefined,
    };

    // Date/Time format
    if (this.timeOnlyValue) {
      options.dateFormat = "";
      options.onSelect = this._createTimeOnlySelectHandler();
    } else if (this.weekPickerValue) {
      options.dateFormat = "";
      options.onSelect = this._createWeekSelectHandler();
    } else if (this.hasDateFormatValue && this.dateFormatValue) {
      options.dateFormat = this.dateFormatValue;
    }

    // Views
    if (this.hasStartViewValue) options.view = this.startViewValue;
    if (this.hasMinViewValue) options.minView = this.minViewValue;

    // Date constraints
    this._setDateConstraint(options, "minDate", this.minDateValue);
    this._setDateConstraint(options, "maxDate", this.maxDateValue);

    // Initial dates
    const initialDates = this._parseInitialDates();
    if (initialDates.length > 0) options.selectedDates = initialDates;

    // Range mode
    if (this.rangeValue) {
      options.range = true;
      options.multipleDatesSeparator = " - ";
    }

    // Timepicker
    if (this.timepickerValue || this.timeOnlyValue) {
      Object.assign(options, {
        timepicker: true,
        timeFormat: this.timeFormatValue || "hh:mm AA",
        ...(this.hasMinHoursValue && { minHours: this.minHoursValue }),
        ...(this.hasMaxHoursValue && { maxHours: this.maxHoursValue }),
        ...(this.hasMinutesStepValue && { minutesStep: this.minutesStepValue }),
      });
      if (this.timeOnlyValue) options.classes = "only-timepicker";
    }

    // Buttons
    const buttons = this._buildButtons();
    if (buttons.length > 0) options.buttons = buttons;

    // Disabled dates handling
    const disabledDates = this._parseDisabledDates();
    if (disabledDates.length > 0) {
      options.onRenderCell = this._createRenderCellHandler(disabledDates);
    }

    // General onSelect for inline calendars
    if (!this.timeOnlyValue && !this.weekPickerValue && this.inlineValue) {
      const originalOnSelect = options.onSelect;
      options.onSelect = (params) => {
        originalOnSelect?.(params);
        setTimeout(() => this.syncToMainPicker(), 10);
        this._triggerChangeEvent();
      };
    }

    // Special handling for inline time-only picker
    if (this.timeOnlyValue && this.inlineValue) {
      const originalOnSelect = options.onSelect;
      options.onSelect = ({ date, datepicker }) => {
        if (originalOnSelect) {
          originalOnSelect({ date, datepicker });
        }
        this._triggerChangeEvent();
      };
    }

    return options;
  }

  _setDateConstraint(options, key, value) {
    if (value) {
      const date = this._parseDate(value);
      if (date) options[key] = date;
    }
  }

  _parseInitialDates() {
    if (!this.hasInitialDateValue || !this.initialDateValue) return [];

    try {
      if (this.initialDateValue.startsWith("[") && this.initialDateValue.endsWith("]")) {
        const dateStrings = JSON.parse(this.initialDateValue);
        return dateStrings.map((str) => this._parseDate(str)).filter(Boolean);
      }
      const date = this._parseDate(this.initialDateValue);
      return date ? [date] : [];
    } catch (e) {
      console.error("Error parsing initialDateValue:", e, "Value was:", this.initialDateValue);
      return [];
    }
  }

  _buildButtons() {
    const buttons = [];
    const buttonConfigs = [
      { condition: this.showTodayButtonValue, button: this._createTodayButton() },
      { condition: this.showThisMonthButtonValue, button: this._createMonthButton() },
      { condition: this.showThisYearButtonValue, button: this._createYearButton() },
      { condition: this.showClearButtonValue, button: "clear" },
    ];

    buttonConfigs.forEach(({ condition, button }) => {
      if (condition && button) buttons.push(button);
    });

    return buttons;
  }

  _createTodayButton() {
    const isTimepickerEnabled = this.timepickerValue || this.timeOnlyValue || this.weekPickerValue;
    const buttonText = this.weekPickerValue ? "This week" : isTimepickerEnabled ? "Now" : "Today";

    return {
      content: buttonText,
      onClick: (dp) => {
        const currentDate = new Date();
        const dates = this.rangeValue ? [currentDate, currentDate] : currentDate;

        if (isTimepickerEnabled && !this.weekPickerValue) {
          dp.clear();
          setTimeout(() => dp.selectDate(dates, { updateTime: true }), 0);
        } else {
          dp.selectDate(dates);
        }
      },
    };
  }

  _createMonthButton() {
    return {
      content: "This month",
      onClick: (dp) => {
        const currentDate = new Date();
        dp.selectDate(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1));
      },
    };
  }

  _createYearButton() {
    return {
      content: "This year",
      onClick: (dp) => {
        const currentDate = new Date();
        dp.selectDate(new Date(currentDate.getFullYear(), 0, 1));
      },
    };
  }

  _parseDisabledDates() {
    if (!this.disabledDatesValue?.length) return [];

    return this.disabledDatesValue.map((str) => this._parseDate(str)).filter(Boolean);
  }

  _createRenderCellHandler(disabledDates) {
    return ({ date, cellType }) => {
      if (cellType !== "day") return {};

      const cellDateUTC = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
      const isDisabled = disabledDates.some((disabledDate) => disabledDate.getTime() === cellDateUTC.getTime());

      return isDisabled ? { disabled: true } : {};
    };
  }

  _createTimeOnlySelectHandler() {
    return ({ date, datepicker }) => {
      if (date) {
        const timeFormat = this.timeFormatValue || "hh:mm AA";
        this.inputTarget.value = datepicker.formatDate(date, timeFormat);
        this._triggerChangeEvent();
      }
    };
  }

  _createWeekSelectHandler() {
    return ({ date }) => {
      if (date) {
        const weekNumber = this._getWeekNumber(date);
        const year = date.getFullYear();
        this.inputTarget.value = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
        this._triggerChangeEvent();
      }
    };
  }

  _createPositionFunction() {
    return ({ $datepicker, $target, $pointer, done }) => {
      const middleware = [offset(8), flip(), shift({ padding: 8 })];

      if ($pointer instanceof HTMLElement) {
        middleware.push(arrow({ element: $pointer, padding: 5 }));
      }

      this._cleanupPositioning();

      this.currentPositionCleanupAutoUpdate = autoUpdate(
        $target,
        $datepicker,
        () => {
          computePosition($target, $datepicker, {
            placement: this.placementValue,
            middleware: middleware,
          }).then(({ x, y, middlewareData }) => {
            Object.assign($datepicker.style, { left: `${x}px`, top: `${y}px` });

            if ($pointer instanceof HTMLElement && middlewareData.arrow) {
              const { x: arrowX, y: arrowY } = middlewareData.arrow;
              Object.assign($pointer.style, {
                left: arrowX != null ? `${arrowX}px` : "",
                top: arrowY != null ? `${arrowY}px` : "",
              });
            }
          });
        },
        { animationFrame: true }
      );

      this.cleanupAutoUpdate = this.currentPositionCleanupAutoUpdate;

      return () => {
        this._cleanupPositioning();
        done();
      };
    };
  }

  _getWeekNumber(date) {
    const tempDate = new Date(date.getTime());
    const dayNumber = (tempDate.getDay() + 6) % 7;
    tempDate.setDate(tempDate.getDate() - dayNumber + 3);

    const firstThursday = new Date(tempDate.getFullYear(), 0, 4);
    firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3);

    return Math.round((tempDate.getTime() - firstThursday.getTime()) / 86400000 / 7) + 1;
  }

  _parseDate(dateString) {
    if (!dateString || typeof dateString !== "string") return null;

    // First check if it's a datetime string (YYYY-MM-DD HH:MM)
    if (dateString.includes(" ")) {
      const [datePart, timePart] = dateString.split(" ");
      const dateParts = datePart.split("-");

      if (dateParts.length === 3 && timePart) {
        const [year, month, day] = dateParts.map(Number);
        const [hours, minutes] = timePart.split(":").map(Number);

        if (!isNaN(year) && !isNaN(month) && !isNaN(day) && !isNaN(hours) && !isNaN(minutes)) {
          return new Date(year, month - 1, day, hours, minutes);
        }
      }
    }

    // Otherwise try to parse as date only (YYYY-MM-DD)
    const parts = dateString.split("-");
    if (parts.length === 3) {
      const [year, month, day] = parts.map(Number);
      if (!isNaN(year) && !isNaN(month) && !isNaN(day)) {
        return new Date(Date.UTC(year, month - 1, day));
      }
    }

    console.warn(`Invalid date string format: ${dateString}. Expected YYYY-MM-DD or YYYY-MM-DD HH:MM.`);
    return null;
  }

  handleKeydown(event) {
    if (event.key === "Delete" || event.key === "Backspace") {
      this.datepickerInstance?.clear();
      this.inputTarget.value = "";
    }
  }

  disconnect() {
    if (this.datepickerInstance) {
      this.datepickerInstance.destroy(); // This should trigger the cleanup returned by position()
      this.datepickerInstance = null;
    }
    // Fallback cleanup for autoUpdate, in case destroy() didn't clear it or it was managed outside position's return.
    if (this.cleanupAutoUpdate) {
      this.cleanupAutoUpdate();
      this.cleanupAutoUpdate = null;
    }
    if (this.currentPositionCleanupAutoUpdate) {
      // Also ensure any lingering position-specific cleanup is called
      this.currentPositionCleanupAutoUpdate();
      this.currentPositionCleanupAutoUpdate = null;
    }

    // Only remove event listener if it was added
    if (this.inputTarget && !this.element.getAttribute("data-controller").includes("dropdown-popover")) {
      this.inputTarget.removeEventListener("keydown", this.handleKeydown.bind(this));
    }
  }

  // Preset time range methods
  setToday(event) {
    const today = new Date();
    // Set hours to noon to avoid any timezone edge cases
    today.setHours(12, 0, 0, 0);
    this._applyPreset(event.currentTarget, today, today);
  }

  setYesterday(event) {
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    this._applyPreset(event.currentTarget, yesterday, yesterday);
  }

  setLastDays(event) {
    const days = parseInt(event.currentTarget.dataset.days, 10);
    if (isNaN(days) || days <= 0) {
      console.warn("Invalid number of days specified in data-days attribute");
      return;
    }

    const endDate = new Date();
    const startDate = new Date();
    startDate.setDate(startDate.getDate() - (days - 1)); // days - 1 because we include today
    this._applyPreset(event.currentTarget, startDate, endDate);
  }

  setPreset(event) {
    const presetType = event.currentTarget.dataset.presetType;

    switch (presetType) {
      case "this-month":
        this._setThisMonth();
        break;
      case "last-month":
        this._setLastMonth();
        break;
      case "this-year":
        this._setThisYear();
        break;
      default:
        console.warn(`Unknown preset type: ${presetType}`);
    }
    this._setActivePreset(event.currentTarget);
  }

  _setThisMonth() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), now.getMonth(), 1);
    const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
    this._applyPreset(event.currentTarget, startDate, endDate);
  }

  _setLastMonth() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
    const endDate = new Date(now.getFullYear(), now.getMonth(), 0);
    this._applyPreset(event.currentTarget, startDate, endDate);
  }

  _setThisYear() {
    const now = new Date();
    const startDate = new Date(now.getFullYear(), 0, 1);
    const endDate = new Date(now.getFullYear(), 11, 31);
    this._applyPreset(event.currentTarget, startDate, endDate);
  }

  clearSelection() {
    if (this.datepickerInstance) {
      this.datepickerInstance.clear();
    }
    this.inputTarget.value = "";
    this._syncToInlineCalendar();
    this._triggerChangeEvent();
    this._clearActivePreset();
  }

  syncFromInlineCalendar(event) {
    // Sync the main input from the inline calendar when it changes
    const inlineInput = event.target;
    if (inlineInput.value) {
      this.inputTarget.value = inlineInput.value;
      this._triggerChangeEvent();
    }
  }

  syncToMainPicker(event = null) {
    // This method is called from the inline calendar to sync to main picker
    // Find the main date picker controller in the parent dropdown
    const dropdownElement = this.element.closest(
      '[data-controller*="dropdown-popover"][data-controller*="date-picker"]'
    );
    if (dropdownElement) {
      const mainController = this.application.getControllerForElementAndIdentifier(dropdownElement, "date-picker");
      if (mainController && mainController !== this) {
        // Get the selected dates from the inline calendar
        const selectedDates = this.datepickerInstance ? this.datepickerInstance.selectedDates : [];

        if (selectedDates.length > 0) {
          // Format the dates for the main input
          const formattedValue = mainController._formatDateRange(
            selectedDates[0],
            selectedDates[selectedDates.length - 1]
          );
          mainController.inputTarget.value = formattedValue;

          // If the main controller has a datepicker instance, sync the selected dates
          if (mainController.datepickerInstance) {
            // For range pickers, ensure we complete the range selection properly
            if (mainController.rangeValue) {
              if (selectedDates.length === 1) {
                // Select the same date twice to complete the range selection
                mainController.datepickerInstance.selectDate([selectedDates[0], selectedDates[0]]);
              } else {
                mainController.datepickerInstance.selectDate(selectedDates);
              }
            } else {
              mainController.datepickerInstance.selectDate(selectedDates[0]);
            }
          }
        } else {
          // Clear the main input if no dates selected
          mainController.inputTarget.value = "";
          if (mainController.datepickerInstance) {
            mainController.datepickerInstance.clear();
          }
        }

        mainController._triggerChangeEvent();

        // Clear active preset when dates are manually selected
        mainController._clearActivePreset();

        // Close the dropdown after selection if we have a complete range or single date
        if (mainController.rangeValue ? selectedDates.length >= 2 : selectedDates.length >= 1) {
          const dropdownController = this.application.getControllerForElementAndIdentifier(
            dropdownElement,
            "dropdown-popover"
          );
          if (dropdownController) {
            dropdownController.close();
          }
        }
      }
    }
  }

  // Helper methods
  _applyPreset(button, startDate, endDate) {
    this._setDateRange(startDate, endDate);
    this._setActivePreset(button);
  }

  _setDateRange(startDate, endDate) {
    if (this.datepickerInstance) {
      const dates = this.rangeValue ? [startDate, endDate] : [startDate];
      this.datepickerInstance.selectDate(dates);
    } else {
      this.inputTarget.value = this._formatDateRange(startDate, endDate);
    }

    this._syncToInlineCalendar();
    this._triggerChangeEvent();
  }

  _formatDateRange(startDate, endDate) {
    const formatDate = (date) => {
      const year = date.getFullYear();
      const month = String(date.getMonth() + 1).padStart(2, "0");
      const day = String(date.getDate()).padStart(2, "0");
      return `${month}/${day}/${year}`;
    };

    return this.rangeValue ? `${formatDate(startDate)} - ${formatDate(endDate)}` : formatDate(startDate);
  }

  _syncToInlineCalendar() {
    // Find inline calendar in the same dropdown and sync its value
    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    if (dropdownMenu) {
      const inlineCalendar = dropdownMenu.querySelector('.inline-calendar[data-controller="date-picker"]');
      if (inlineCalendar) {
        const inlineController = this.application.getControllerForElementAndIdentifier(inlineCalendar, "date-picker");
        if (inlineController && inlineController.datepickerInstance) {
          // Get selected dates from main picker and apply to inline calendar
          if (this.datepickerInstance && this.datepickerInstance.selectedDates.length > 0) {
            // If it's a range picker and both dates are the same, only select one date
            const datesToSelect =
              this.rangeValue &&
              this.datepickerInstance.selectedDates.length === 2 &&
              this.datepickerInstance.selectedDates[0].getTime() === this.datepickerInstance.selectedDates[1].getTime()
                ? [this.datepickerInstance.selectedDates[0]]
                : this.datepickerInstance.selectedDates;
            inlineController.datepickerInstance.selectDate(datesToSelect);
          } else {
            // If no main datepicker instance, parse the input value and set dates
            if (this.inputTarget.value) {
              const dates = this._parseDateRangeValue(this.inputTarget.value);
              if (dates.length > 0) {
                // Clear any existing selection first to ensure clean state
                inlineController.datepickerInstance.clear();

                // If it's a range picker and we have a single date or both parsed dates are the same
                if (inlineController.rangeValue) {
                  if (dates.length === 1) {
                    // For single date in range mode, select it twice to complete the range
                    inlineController.datepickerInstance.selectDate([dates[0], dates[0]]);
                  } else if (dates.length === 2 && dates[0].getTime() === dates[1].getTime()) {
                    // For identical start/end dates, select twice to complete the range
                    inlineController.datepickerInstance.selectDate([dates[0], dates[0]]);
                  } else {
                    // For different dates, select normally
                    inlineController.datepickerInstance.selectDate(dates);
                  }
                } else {
                  // Non-range mode, select normally
                  inlineController.datepickerInstance.selectDate(dates);
                }
              }
            } else {
              inlineController.datepickerInstance.clear();
            }
          }
        }
      }
    }
  }

  _parseDateRangeValue(value) {
    // Parse a date range string like "01/15/2025 - 01/20/2025" or "01/15/2025"
    const dates = [];
    if (value.includes(" - ")) {
      const [startStr, endStr] = value.split(" - ");
      const startDate = this._parseFormattedDate(startStr.trim());
      const endDate = this._parseFormattedDate(endStr.trim());
      if (startDate) dates.push(startDate);
      if (endDate) dates.push(endDate);
    } else {
      const date = this._parseFormattedDate(value.trim());
      if (date) dates.push(date);
    }
    return dates;
  }

  _parseFormattedDate(dateStr) {
    // Parse MM/DD/YYYY format
    const parts = dateStr.split("/");
    if (parts.length === 3) {
      const month = parseInt(parts[0], 10) - 1; // Month is 0-indexed
      const day = parseInt(parts[1], 10);
      const year = parseInt(parts[2], 10);
      return new Date(year, month, day);
    }
    return null;
  }

  _triggerChangeEvent() {
    this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
  }

  _setActivePreset(button) {
    this._clearActivePreset();
    if (button) {
      button.classList.remove(...this.constructor.PRESET_CLASSES.inactive);
      button.classList.add(...this.constructor.PRESET_CLASSES.active);
    }
  }

  _clearActivePreset() {
    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    const presetButtons =
      dropdownMenu?.querySelectorAll('[data-menu-target="item"]:not([data-action*="clearSelection"])') || [];

    presetButtons.forEach((button) => {
      button.classList.remove(...this.constructor.PRESET_CLASSES.active);
      button.classList.add(...this.constructor.PRESET_CLASSES.inactive);
    });
  }

  _detectActivePreset() {
    const dateRangeValue = this.inputTarget.value;
    if (!dateRangeValue) return;

    const dates = this._parseDateRangeValue(dateRangeValue);
    if (dates.length !== 2) return;

    const [startDate, endDate] = dates;
    const today = new Date();
    [startDate, endDate, today].forEach((date) => date.setHours(0, 0, 0, 0));

    const dropdownMenu = this.element.querySelector('[data-dropdown-popover-target="menu"]');
    const presetButtons =
      dropdownMenu?.querySelectorAll('[data-menu-target="item"]:not([data-action*="clearSelection"])') || [];

    presetButtons.forEach((button) => {
      const isMatch = this._checkPresetMatch(button, startDate, endDate, today);
      if (isMatch) this._setActivePreset(button);
    });
  }

  _checkPresetMatch(button, startDate, endDate, today) {
    const action = button.dataset.action;

    if (action?.includes("setToday")) {
      return this._isSameDay(startDate, today) && this._isSameDay(endDate, today);
    }

    if (action?.includes("setYesterday")) {
      const yesterday = new Date(today);
      yesterday.setDate(yesterday.getDate() - 1);
      return this._isSameDay(startDate, yesterday) && this._isSameDay(endDate, yesterday);
    }

    if (action?.includes("setLastDays")) {
      const days = parseInt(button.dataset.days, 10);
      const expectedStart = new Date(today);
      expectedStart.setDate(expectedStart.getDate() - (days - 1));
      return this._isSameDay(startDate, expectedStart) && this._isSameDay(endDate, today);
    }

    if (action?.includes("setPreset")) {
      const presetType = button.dataset.presetType;
      const presetRanges = {
        "this-month": [
          new Date(today.getFullYear(), today.getMonth(), 1),
          new Date(today.getFullYear(), today.getMonth() + 1, 0),
        ],
        "last-month": [
          new Date(today.getFullYear(), today.getMonth() - 1, 1),
          new Date(today.getFullYear(), today.getMonth(), 0),
        ],
        "this-year": [new Date(today.getFullYear(), 0, 1), new Date(today.getFullYear(), 11, 31)],
      };

      const range = presetRanges[presetType];
      return range && this._isSameDay(startDate, range[0]) && this._isSameDay(endDate, range[1]);
    }

    return false;
  }

  _isSameDay(date1, date2) {
    return (
      date1.getFullYear() === date2.getFullYear() &&
      date1.getMonth() === date2.getMonth() &&
      date1.getDate() === date2.getDate()
    );
  }

  _updateWeekDisplay() {
    if (!this.datepickerInstance) return;

    const selectedDates = this.datepickerInstance.selectedDates;
    if (selectedDates.length > 0) {
      const initialDate = selectedDates[0];
      const weekNumber = this._getWeekNumber(initialDate);
      const year = initialDate.getFullYear();
      this.inputTarget.value = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
      this._triggerChangeEvent();
    } else {
      this.inputTarget.value = "";
      this._triggerChangeEvent();
    }
  }

  _cleanup() {
    if (this.datepickerInstance) {
      this.datepickerInstance.destroy();
      this.datepickerInstance = null;
    }
    this._cleanupPositioning();
  }

  _cleanupPositioning() {
    [this.cleanupAutoUpdate, this.currentPositionCleanupAutoUpdate].forEach((cleanup) => {
      if (cleanup) cleanup();
    });
    this.cleanupAutoUpdate = null;
    this.currentPositionCleanupAutoUpdate = null;
  }
}

2. Dependencies Installation

The date picker component relies on AirDatepicker and Floating UI for positioning. Choose your preferred installation method:

pin "air-datepicker", to: "https://esm.sh/air-datepicker@3.6.0"
pin "air-datepicker/locale/en", to: "https://esm.sh/air-datepicker@3.6.0/locale/en"
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.0/+esm"
Terminal
npm install air-datepicker
npm install @floating-ui/dom
Terminal
yarn add air-datepicker
yarn add @floating-ui/dom

Now add this to your <head> HTML tag:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/air-datepicker@3.6.0/air-datepicker.min.css">

3. Custom CSS

Here are the custom CSS classes that we used on Rails Blocks to style the date picker components. You can copy and paste these into your own CSS file to style & personalize your date pickers.

/* air-datepicker */

.air-datepicker {
  --adp-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
    "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  --adp-font-size: 14px;
  --adp-width: 246px;
  --adp-z-index: 40;
  --adp-padding: 4px;
  --adp-grid-areas: "nav" "body" "timepicker" "buttons";
  --adp-transition-duration: 0.3s;
  --adp-transition-ease: ease-out;
  --adp-transition-offset: 8px;
  --adp-background-color: #fff;
  --adp-background-color-hover: #f0f0f0;
  --adp-background-color-active: #eaeaea;
  --adp-background-color-in-range: rgba(16, 16, 16, 0.1);
  --adp-background-color-in-range-focused: rgba(236, 236, 236, 0.2);
  --adp-background-color-selected-other-month-focused: #f1f1f1;
  --adp-background-color-selected-other-month: #e6e6e6;
  --adp-color: #4a4a4a;
  --adp-color-secondary: #9c9c9c;
  --adp-accent-color: #0a0a0a;
  --adp-color-current-date: var(--adp-accent-color);
  --adp-color-other-month: #dedede;
  --adp-color-disabled: #aeaeae;
  --adp-color-disabled-in-range: #939393;
  --adp-color-other-month-hover: #c5c5c5;
  --adp-border-color: rgba(0, 0, 0, 0.1);
  --adp-border-color-inner: #efefef;
  --adp-border-radius: 8px;
  --adp-border-color-inline: #d7d7d7;
  --adp-nav-height: 32px;
  --adp-nav-arrow-color: var(--adp-color-secondary);
  --adp-nav-action-size: 32px;
  --adp-nav-color-secondary: var(--adp-color-secondary);
  --adp-day-name-color: #464646;
  --adp-day-name-color-hover: #f1f1f1;
  --adp-day-cell-width: 1fr;
  --adp-day-cell-height: 32px;
  --adp-month-cell-height: 42px;
  --adp-year-cell-height: 56px;
  --adp-pointer-size: 10px;
  --adp-poiner-border-radius: 2px;
  --adp-pointer-offset: 14px;
  --adp-cell-border-radius: 4px;
  --adp-cell-background-color-hover: var(--adp-background-color-hover);
  --adp-cell-background-color-selected: #1d1d1d;
  --adp-cell-background-color-selected-hover: #303030;
  --adp-cell-background-color-in-range: rgba(38, 38, 38, 0.1);
  --adp-cell-background-color-in-range-hover: rgba(44, 44, 44, 0.2);
  --adp-cell-border-color-in-range: var(--adp-cell-background-color-selected);
  --adp-btn-height: 32px;
  --adp-btn-color: var(--adp-accent-color);
  --adp-btn-color-hover: var(--adp-color);
  --adp-btn-border-radius: var(--adp-border-radius);
  --adp-btn-background-color-hover: var(--adp-background-color-hover);
  --adp-btn-background-color-active: var(--adp-background-color-active);
  --adp-time-track-height: 1px;
  --adp-time-track-color: #dedede;
  --adp-time-track-color-hover: #b1b1b1;
  --adp-time-thumb-size: 12px;
  --adp-time-padding-inner: 10px;
  --adp-time-day-period-color: var(--adp-color-secondary);
  --adp-mobile-font-size: 16px;
  --adp-mobile-nav-height: 40px;
  --adp-mobile-width: 320px;
  --adp-mobile-day-cell-height: 38px;
  --adp-mobile-month-cell-height: 48px;
  --adp-mobile-year-cell-height: 64px;
}
.air-datepicker-overlay {
  --adp-overlay-background-color: rgba(0, 0, 0, 0.3);
  --adp-overlay-transition-duration: 0.3s;
  --adp-overlay-transition-ease: ease-out;
  --adp-overlay-z-index: 99;
}

.air-datepicker-cell.-selected-.-day- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-year- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-month- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-selected-.-current- {
  color: #ffffff; /* Ensure selected cell text is also light in dark mode */
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-current- {
  border: 1px solid #bdbdbd !important;
}

.air-datepicker-cell.-selected-.-day-.-other-month- {
  color: #cccccc; /* Ensure selected cell text is also light in dark mode */
  background: #7e7e7e;
}

.dark {
  .air-datepicker {
    --adp-background-color: #2d2d2d; /* Dark background */
    --adp-background-color-hover: #3a3a3a;
    --adp-background-color-active: #4a4a4a;
    --adp-background-color-in-range: rgba(196, 196, 196, 0.2);
    --adp-background-color-in-range-focused: rgba(196, 196, 196, 0.3);
    --adp-background-color-selected-other-month-focused: #333;
    --adp-background-color-selected-other-month: #444;
    --adp-color: #e0e0e0; /* Light text color */
    --adp-color-secondary: #a0a0a0;
    --adp-accent-color: #ffffff; /* A light accent color for dark mode */
    --adp-color-other-month: #555;
    --adp-color-disabled: #777;
    --adp-color-disabled-in-range: #888;
    --adp-color-other-month-hover: #666;
    --adp-border-color: #ffffff1a; /* Lighter border for dark mode */
    --adp-border-color-inner: #444;
    --adp-border-color-inline: #444444;
    --adp-nav-arrow-color: var(--adp-color-secondary);
    --adp-day-name-color: #c0c0c0;
    --adp-day-name-color-hover: #3a3a3a;
    --adp-cell-background-color-hover: var(--adp-background-color-hover);
    --adp-cell-background-color-selected: #ffffff;
    --adp-cell-background-color-selected-hover: #e9e9e9;
    --adp-cell-background-color-in-range: rgba(175, 175, 175, 0.2);
    --adp-cell-background-color-in-range-hover: rgba(169, 169, 169, 0.3);
    --adp-btn-color: var(--adp-accent-color);
    --adp-btn-color-hover: var(--adp-color);
    --adp-btn-background-color-hover: var(--adp-background-color-hover);
    --adp-btn-background-color-active: var(--adp-background-color-active);
    --adp-time-track-color: #555;
    --adp-time-track-color-hover: #777;
    --adp-time-day-period-color: var(--adp-color-secondary);
  }

  .air-datepicker-overlay {
    --adp-overlay-background-color: rgba(255, 255, 255, 0.1); /* Lighter overlay for dark mode */
  }

  .air-datepicker-cell.-selected-.-day- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-year- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-month- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-selected-.-current- {
    color: #1d1d1d; /* Ensure selected cell text is also light in dark mode */
    background: var(--adp-cell-background-color-selected);
  }

  .air-datepicker-cell.-current- {
    border: 1px solid #505050 !important;
  }

  .air-datepicker-cell.-selected-.-day-.-other-month- {
    color: #cccccc; /* Ensure selected cell text is also light in dark mode */
    background: #7e7e7e;
  }
}

.air-datepicker--navigation {
  width: 100%;
}
.air-datepicker--pointer {
  opacity: 0;
}
.air-datepicker {
  background: var(--adp-background-color);
  border: 1px solid var(--adp-border-color);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  border-radius: var(--adp-border-radius);
  box-sizing: content-box;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: repeat(4, max-content);
  grid-template-areas: var(--adp-grid-areas);
  font-family: var(--adp-font-family), sans-serif;
  font-size: var(--adp-font-size);
  color: var(--adp-color);
  width: var(--adp-width);
  position: absolute;
  transition: opacity var(--adp-transition-duration) var(--adp-transition-ease),
    transform var(--adp-transition-duration) var(--adp-transition-ease);
  z-index: var(--adp-z-index);
}

.air-datepicker-cell.-selected- {
  color: #1d1d1d;
  border: none;
  background: var(--adp-cell-background-color-selected);
}

.air-datepicker-cell.-disabled- {
  cursor: not-allowed;
}

/* Add a new rule for dark mode selected cells if needed, or adjust the general .air-datepicker-cell.-selected- inside the dark mode media query */
.dark {
  .air-datepicker-cell.-selected- {
    color: #e0e0e0; /* Example: light text for selected cells in dark mode */
    /* background: var(--adp-cell-background-color-selected); Is already set within .air-datepicker dark vars */
  }
}

/* Time-only picker styles */
.air-datepicker.only-timepicker .air-datepicker--navigation {
  display: none;
}

.air-datepicker.only-timepicker .air-datepicker--content {
  display: none;
}

.air-datepicker.only-timepicker .air-datepicker--time {
  border-top: none;
}

Examples

Basic Date Picker

A simple date picker with default options.

Input Version

Inline Version (Always Visible)

Selected: Today
<%# Basic Date Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Always Visible)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="basic-inline-display-<%= Time.now.to_i %>">Today</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('basic-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Picker with Initial Date

Pre-populated with a specific date.

Input Version

Inline Version (Pre-selected: August 15, 2024)

Selected: 08/15/2024
<%# Date Picker with Initial Date %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-initial-date-value="2024-08-15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: August 15, 2024)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-initial-date-value="2024-08-15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="initial-inline-display-<%= Time.now.to_i %>">08/15/2024</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('initial-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Range Picker

Select a date range with start and end dates.

Input Version

Inline Version (Pre-selected: Last 7 days)

Selected: 08/05/2025 - 08/11/2025
<%# Date Range Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-range-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date range...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: Last 7 days)</h4>
    <%
      end_date = Date.today
      start_date = end_date - 6
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-range-value="true"
         data-date-picker-initial-date-value='["<%= start_date.strftime('%Y-%m-%d') %>", "<%= end_date.strftime('%Y-%m-%d') %>"]'
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="range-inline-display-<%= Time.now.to_i %>"><%= start_date.strftime('%m/%d/%Y') %> - <%= end_date.strftime('%m/%d/%Y') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('range-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date & Time Picker

Select both date and time in a single picker.

Input Version

Inline Version (Pre-selected: Today at 2:30 PM)

Selected: 08/11/2025 02:30 PM
<%# Date Picker with Time %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-timepicker-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date & time...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: Today at 2:30 PM)</h4>
    <%
      selected_date = Date.today
      # Create a datetime with today's date at 2:30 PM
      selected_datetime = Time.new(selected_date.year, selected_date.month, selected_date.day, 14, 30, 0)
      formatted_date = selected_datetime.strftime('%Y-%m-%d %H:%M')
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-timepicker-value="true"
         data-date-picker-initial-date-value="<%= formatted_date %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="datetime-inline-display-<%= Time.now.to_i %>"><%= selected_datetime.strftime('%m/%d/%Y') %> 02:30 PM</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('datetime-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Time Only Picker

Select only time without date.

Input Version

24h Format Version

Inline Version (Pre-selected: 3:00 PM)

Selected: 03:00 PM
<%# Time Only Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-time-only-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select time...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></g></svg>
      </div>
    </div>
  </div>

  <%# 24h Format Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">24h Format Version</h4>
    <div data-controller="date-picker"
         data-date-picker-time-only-value="true"
         data-date-picker-time-format-value="HH:mm"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select time...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: 3:00 PM)</h4>
    <%
      # Create a datetime with today's date at 3:00 PM
      selected_time = Time.new(Date.today.year, Date.today.month, Date.today.day, 15, 0, 0)
      formatted_time = selected_time.strftime('%Y-%m-%d %H:%M')
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-time-only-value="true"
         data-date-picker-initial-date-value="<%= formatted_time %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="time-inline-display-<%= Time.now.to_i %>">03:00 PM</span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('time-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Week Picker

Select weeks in ISO 8601 format (YYYY-WXX).

Input Version

Inline Version (Pre-selected: This week)

Selected: 2025-W33
<%# Week Picker %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-week-picker-value="true"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select week (YYYY-WXX)...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: This week)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-week-picker-value="true"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        <%
          # Calculate current week number
          current_date = Date.today
          week_number = current_date.strftime('%V').to_i
          year = current_date.year
        %>
        Selected: <span id="week-inline-display-<%= Time.now.to_i %>"><%= year %>-W<%= week_number.to_s.rjust(2, '0') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('week-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Month Selector

Select only month and year.

Input Version

Inline Version (Pre-selected: This month)

Selected: August 2025
<%# Month Selector %>
<div class="space-y-8">
  <%# Input Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input Version</h4>
    <div data-controller="date-picker"
         data-date-picker-start-view-value="months"
         data-date-picker-min-view-value="months"
         data-date-picker-date-format-value="MMMM yyyy"
         data-date-picker-show-this-month-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select month...">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Version %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Version (Pre-selected: This month)</h4>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-start-view-value="months"
         data-date-picker-min-view-value="months"
         data-date-picker-date-format-value="MMMM yyyy"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-show-this-month-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        Selected: <span id="month-inline-display-<%= Time.now.to_i %>"><%= Date.today.strftime('%B %Y') %></span>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('month-inline-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Calendar with constraints

Calendar with constraints to change to picker with constraints

Input with Date Constraints

Inline Calendar with Date & Time Constraints

Selected: 08/18/2025 09:00 AM
Constraints: Today to +30 days, 9AM-5PM, 15min steps
<%# Date Pickers with Constraints %>
<div class="space-y-8">
  <%# Input with Date Constraints %>
  <div>
    <h4 class="font-medium text-sm mb-3">Input with Date Constraints</h4>
    <%
      min_date = Date.today - 7
      max_date = Date.today + 14
    %>
    <div data-controller="date-picker"
         data-date-picker-initial-date-value="<%= Date.today.strftime('%Y-%m-%d') %>"
         data-date-picker-min-date-value="<%= min_date.strftime('%Y-%m-%d') %>"
         data-date-picker-max-date-value="<%= max_date.strftime('%Y-%m-%d') %>"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="relative w-full max-w-sm">
      <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
             readonly
             data-date-picker-target="input"
             placeholder="Select date..."
             value="<%= Date.today.strftime('%m/%d/%Y') %>">
      <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18"><g fill="currentColor"><path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path><path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path></g></svg>
      </div>
    </div>
  </div>

  <%# Inline Calendar with Date & Time Constraints %>
  <div>
    <h4 class="font-medium text-sm mb-3">Inline Calendar with Date & Time Constraints</h4>
    <%
      min_date = Date.today
      max_date = Date.today + 30
    %>
    <div data-controller="date-picker"
         data-date-picker-inline-value="true"
         data-date-picker-timepicker-value="true"
         data-date-picker-initial-date-value="<%= (Date.today + 7).strftime('%Y-%m-%d') %>"
         data-date-picker-min-date-value="<%= min_date.strftime('%Y-%m-%d') %>"
         data-date-picker-max-date-value="<%= max_date.strftime('%Y-%m-%d') %>"
         data-date-picker-min-hours-value="9"
         data-date-picker-max-hours-value="17"
         data-date-picker-minutes-step-value="15"
         data-date-picker-show-today-button-value="true"
         data-date-picker-show-clear-button-value="true"
         class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 w-full max-w-sm flex items-center flex-col justify-center text-center gap-2">
      <input data-date-picker-target="input" class="hidden">
      <div class="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
        <div>Selected: <span id="inline-constrained-display-<%= Time.now.to_i %>"><%= (Date.today + 7).strftime('%m/%d/%Y') %> 09:00 AM</span></div>
        <div class="text-xs mt-1">Constraints: Today to +30 days, 9AM-5PM, 15min steps</div>
      </div>
    </div>
    <script>
      document.addEventListener('turbo:load', function() {
        const display = document.getElementById('inline-constrained-display-<%= Time.now.to_i %>');
        const input = display.closest('[data-controller="date-picker"]').querySelector('[data-date-picker-target="input"]');

        input.addEventListener('change', function() {
          display.textContent = input.value || 'None';
        });
      });
    </script>
  </div>
</div>

Date Range with Presets

Comprehensive date picker with predefined time ranges and inline calendar. This date picker requires the dropdown component.



<%# Date Range Picker with Presets %>
<div class="relative w-full max-w-xs"
     data-controller="dropdown-popover date-picker"
     data-dropdown-popover-placement-value="bottom-start"
     data-dropdown-popover-auto-position-value="true"
     data-date-picker-range-value="true">

  <input class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm pr-10"
         readonly
         data-date-picker-target="input"
         data-dropdown-popover-target="button"
         data-action="click->dropdown-popover#toggle"
         placeholder="Select date range...">

  <div class="pointer-events-none absolute inset-y-0 right-3 flex items-center justify-center opacity-80">
    <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 18 18">
      <g fill="currentColor">
        <path d="M5.75,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336,.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path>
        <path d="M12.25,3.5c-.414,0-.75-.336-.75-.75V.75c0-.414,.336-.75,.75-.75s.75,.336-.75,.75V2.75c0,.414-.336,.75-.75,.75Z"></path>
        <path d="M13.75,2H4.25c-1.517,0-2.75,1.233-2.75,2.75V13.25c0,1.517,1.233,2.75,2.75,2.75H13.75c1.517,0,2.75-1.233,2.75-2.75V4.75c0-1.517-1.233-2.75-2.75-2.75Zm0,12.5H4.25c-.689,0-1.25-.561-1.25-1.25V7H15v6.25c0,.689-.561,1.25-1.25,1.25Z"></path>
      </g>
    </svg>
  </div>

  <dialog data-dropdown-popover-target="menu"
          data-controller="menu"
          data-action="click@document->dropdown-popover#closeOnClickOutside keydown.up->menu#prev keydown.down->menu#next keydown.up->menu#preventScroll keydown.down->menu#preventScroll"
          class="outline-hidden absolute z-50 rounded-lg border border-neutral-200 bg-white text-neutral-900 shadow-md transition-opacity ease-out duration-150 opacity-0 dark:bg-neutral-800 dark:text-neutral-100 dark:border-neutral-700/50 [&[open]]:scale-100 [&[open]]:opacity-100"
          data-menu-index-value="-1">

    <div class="flex flex-col sm:flex-row">
      <!-- Left side: Time range presets -->
      <div class="border-b sm:border-b-0 sm:border-r border-neutral-200 dark:border-neutral-700 px-2 py-3">
        <div class="space-y-0">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setToday">
            Today
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setYesterday">
            Yesterday
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="7">
            Last 7 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="30">
            Last 30 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="90">
            Last 90 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="180">
            Last 180 days
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setLastDays"
                  data-days="365">
            Last 365 days
          </button>
          <hr class="my-2 border-neutral-200 dark:border-neutral-700">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="this-month">
            This month
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="last-month">
            Last month
          </button>
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-neutral-700 hover:bg-neutral-100 focus:bg-neutral-100 focus:text-neutral-900 dark:focus:text-white focus:outline-hidden dark:text-neutral-300 dark:hover:bg-neutral-700/50 dark:focus:bg-neutral-700/50 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#setPreset"
                  data-preset-type="this-year">
            This year
          </button>
          <hr class="my-2 border-neutral-200 dark:border-neutral-700">
          <button type="button"
                  class="w-full text-left whitespace-nowrap text-xs px-2 py-1.5 rounded-md text-red-600 hover:bg-red-50 focus:bg-red-50 focus:outline-hidden dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:bg-red-900/20 transition-colors"
                  data-menu-target="item"
                  data-action="click->date-picker#clearSelection">
            Clear & Close
          </button>
        </div>
      </div>

      <!-- Right side: Inline calendar -->
      <div class="flex p-3 justify-center items-center sm:px-6">
        <div data-controller="date-picker"
             data-date-picker-inline-value="true"
             data-date-picker-range-value="true"
             data-date-picker-show-clear-button-value="true"
             class="inline-calendar">
          <input data-date-picker-target="input"
                 class="hidden">
        </div>
      </div>
    </div>
  </dialog>
</div>

Native HTML Date Fields

Browser native date inputs for comparison and fallback scenarios.

Native HTML week input - displays format like "2025-W21" and provides native week selection UI

to

Only allows dates within ±7 days from today

<%# HTML Native Date & Time Fields %>
<div class="w-full max-w-sm space-y-6">
  <div class="relative gap-y-1.5">
    <label for="date">Date</label>
    <input type="date" name="date" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="datetime">Date & Time</label>
    <input type="datetime-local" name="datetime" value="<%= DateTime.now.strftime('%Y-%m-%dT%H:%M') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="time">Time Only</label>
    <input type="time" name="time" value="<%= Time.now.strftime('%H:%M') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="week">Week</label>
    <input type="week" name="week" value="<%= Date.today.strftime('%Y-W%V') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
    <p class="text-neutral-600 dark:text-neutral-300/75 text-xs italic mt-1">Native HTML week input - displays format like "2025-W21" and provides native week selection UI</p>
  </div>

  <div class="relative gap-y-1.5">
    <label for="month">Month</label>
    <input type="month" name="month" value="<%= Date.today.strftime('%Y-%m') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm">
  </div>

  <div class="relative gap-y-1.5">
    <label for="date_range">Date Range (using two inputs)</label>
    <div class="flex items-center gap-2">
      <input type="date" name="range_start" value="<%= (Date.today - 7).strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm" placeholder="Start date">
      <span class="text-neutral-500">to</span>
      <input type="date" name="range_end" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm" placeholder="End date">
    </div>
  </div>

  <div class="relative gap-y-1.5">
    <label for="date_min_max">Date with Min/Max Constraints</label>
    <input type="date" name="date_min_max" value="<%= Date.today.strftime('%Y-%m-%d') %>" class="block w-full rounded-lg border-0 px-3 py-2 text-neutral-900 shadow-sm ring-1 ring-inset ring-neutral-300 placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-600 outline-hidden leading-6 dark:bg-neutral-700 dark:ring-neutral-600 dark:placeholder-neutral-300 dark:text-white dark:focus:ring-neutral-500 text-base sm:text-sm"
           min="<%= (Date.today - 7).strftime('%Y-%m-%d') %>"
           max="<%= (Date.today + 7).strftime('%Y-%m-%d') %>">
    <p class="text-neutral-600 dark:text-neutral-300/75 text-xs italic mt-1">Only allows dates within ±7 days from today</p>
  </div>
</div>

Configuration

The date picker component is powered by AirDatepicker and a Stimulus controller that provides extensive configuration options.

Controller Setup

Basic date picker structure with required data attributes:

<div data-controller="date-picker">
  <input type="text"
         data-date-picker-target="input"
         class="form-input"
         placeholder="Select date" />
</div>

Configuration Values

Prop Description Type Default
range
Enable date range selection mode Boolean false
timepicker
Enable time selection along with date Boolean false
timeOnly
Show only time picker without date Boolean false
weekPicker
Enable week selection mode (ISO 8601 format) Boolean false
inline
Make the calendar always visible Boolean false
dateFormat
Custom date format (e.g., 'dd/MM/yyyy', 'MMMM yyyy') String "MM/dd/yyyy"
timeFormat
Time format when timepicker is enabled (e.g., 'HH:mm' for 24h) String "hh:mm AA"
minDate
Minimum selectable date in YYYY-MM-DD format String None
maxDate
Maximum selectable date in YYYY-MM-DD format String None
initialDate
Initial selected date(s). Single date as 'YYYY-MM-DD' or range as JSON array String None
disabledDates
Array of dates to disable in YYYY-MM-DD format Array []
showTodayButton
Show Today/Now/This week button (context-aware) Boolean false
showClearButton
Show Clear button to reset selection Boolean false
showThisMonthButton
Show This Month button for month pickers Boolean false
showThisYearButton
Show This Year button for year pickers Boolean false
placement
Floating UI placement for the dropdown String "bottom-start"
startView
Initial view ('days', 'months', 'years') String "days"
minView
Minimum view level ('days', 'months', 'years') String "days"
minHours
Minimum selectable hour (0-23) Number None
maxHours
Maximum selectable hour (0-23) Number None
minutesStep
Step increment for minutes selection Number 1

Targets

Target Description Required
input
The input field that displays the selected date and triggers the calendar Required
inlineCalendar
Container for inline calendar displays (used with inline mode) Optional

Actions

Action Description Usage
setToday
Set date to today click->date-picker#setToday
setYesterday
Set date to yesterday click->date-picker#setYesterday
setLastDays
Set date range for last N days (requires data-days attribute) click->date-picker#setLastDays
setPreset
Set predefined date ranges (requires data-preset-type attribute) click->date-picker#setPreset
clearSelection
Clear the current date selection click->date-picker#clearSelection
syncFromInlineCalendar
Sync selection from inline calendar change->date-picker#syncFromInlineCalendar
syncToMainPicker
Sync inline calendar to main picker change->date-picker#syncToMainPicker

Features

  • Flexible Date Selection: Single dates, date ranges, weeks, months, or years
  • Time Selection: Combined date & time or time-only modes with customizable formats
  • Smart Buttons: Context-aware buttons (Today becomes Now for time pickers, This week for week pickers)
  • Inline Mode: Always-visible calendar widgets for dashboards
  • Presets: Quick selection with predefined date ranges
  • Keyboard Navigation: Full keyboard support with Delete/Backspace to clear
  • Dark Mode: Full dark mode support with Tailwind CSS
  • Floating UI: Smart positioning that adapts to viewport constraints

Internationalization

To use different languages, install locale files from the AirDatepicker repository and import them in your controller:

import localeEs from "air-datepicker/locale/es";
// In the controller
this.datepickerInstance = new AirDatepicker(this.inputTarget, {
  locale: localeEs,
  // ... other options
});

Week Picker Details

The week picker feature provides a more accessible alternative to the native HTML week input:

  • ISO 8601 Format: Outputs weeks in standard YYYY-WXX format (e.g., "2025-W21")
  • Week Calculation: Week 1 is the first week with at least 4 days in the new year
  • Visual Indicators: Shows week numbers on calendar cells
  • Cross-browser: Consistent behavior across all browsers

Table of contents

Get notified when new components come out