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"
npm install air-datepicker
npm install @floating-ui/dom
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)
<%# 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)
<%# 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)
<%# 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)
<%# 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)
<%# 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)
<%# 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)
<%# 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
<%# 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
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