Skip to content

Date Picker

An accessible, keyboard-navigable date picker with an inline calendar popup. Supports day / month / year drill-down views, min/max bounds, disabled weekend days, and native form association.

Features

  • Full Keyboard Nav — Enter, Space, Escape, Arrow keys on individual day cells
  • ARIArole="combobox" trigger, role="dialog" calendar, role="grid" day grid, role="gridcell" day cells, aria-selected, aria-current="date" for today
  • Three views — Day → Month → Year drill-down with animated header label cycling
  • Internationalised — Uses Intl.DateTimeFormat; pass any BCP 47 locale string
  • Min / Max bounds — ISO 8601 min / max attributes; out-of-range days are disabled
  • Weekend disablingweekend-days="0,6" disables Sundays and Saturdays
  • Form-Associated — participates in native form submission; value is the ISO date string
  • 3 Sizes — sm, md, lg
  • 5 Variants — solid, flat, bordered, outline, ghost
  • 6 Semantic Colors — primary, secondary, info, success, warning, error
  • Helper & Error Text — inline assistive text below the trigger

Source Code

View Source Code
ts
import { define, useField, html, inject, prop } from '@vielzeug/craft';
import { computed, signal } from '@vielzeug/ripple';
import { Temporal, format } from '@vielzeug/tempo';

import type { ComponentSize, RoundedSize, ThemeColor, VisualVariant } from '../../shared';

import { createDatePickerControl, formatDisplayDate, parseIso, toIsoString, type DatePickerView } from '../../headless';
import '../../content/icon/icon';
import '../input/input';
import { disablableBundle, roundableBundle, sizableBundle, themableBundle } from '../../shared';
import { colorThemeMixin, reducedMotionMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './date-picker.css?inline';

// ── Types ─────────────────────────────────────────────────────────────────────

export type SgDatePickerEvents = {
  change: { isoValue: string | null };
};

export type SgDatePickerProps = {
  /** Theme color */
  color?: ThemeColor;
  /** Disable the picker */
  disabled?: boolean;
  /** Validation error message */
  error?: string;
  /** Expand to container width */
  fullwidth?: boolean;
  /** Helper text below the field */
  helper?: string;
  /** Visible label */
  label?: string;
  /** Label placement */
  'label-placement'?: 'inset' | 'outside';
  /** Locale for day/month names (default: browser locale) */
  locale?: string;
  /** Latest selectable date in ISO 8601 format (yyyy-MM-dd) */
  max?: string;
  /** Earliest selectable date in ISO 8601 format (yyyy-MM-dd) */
  min?: string;
  /** Form field name */
  name?: string;
  /** Placeholder shown in the text trigger */
  placeholder?: string;
  /** Mark field as required */
  required?: boolean;
  /** Border radius */
  rounded?: RoundedSize;
  /** Component size */
  size?: ComponentSize;
  /**
   * Selected date in ISO 8601 format (yyyy-MM-dd).
   * @example '2025-06-15'
   */
  value?: string;
  /** Visual variant */
  variant?: Exclude<VisualVariant, 'text' | 'frost'>;
  /**
   * Day-of-week indices to disable (0 = Sunday … 6 = Saturday).
   * Pass as a JSON array attribute or a JS property.
   * @example
   * ```html
   * <sg-date-picker weekend-days="[0,6]"></sg-date-picker>
   * ```
   */
  'weekend-days'?: number[];
};

/**
 * An accessible, keyboard-navigable date picker with an inline calendar popup.
 * Supports min/max bounds, disabled weekend days, and form association.
 *
 * @element sg-date-picker
 *
 * @attr {string} value - Selected date in ISO 8601 format (yyyy-MM-dd)
 * @attr {string} min - Minimum selectable date (yyyy-MM-dd)
 * @attr {string} max - Maximum selectable date (yyyy-MM-dd)
 * @attr {string} label - Label text
 * @attr {string} label-placement - 'inset' | 'outside'
 * @attr {string} placeholder - Trigger placeholder
 * @attr {boolean} disabled - Disable the picker
 * @attr {boolean} required - Required field
 * @attr {string} name - Form field name
 * @attr {string} error - Error message
 * @attr {string} helper - Helper text
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} size - Component size: 'sm' | 'md' | 'lg'
 * @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost'
 * @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
 * @attr {string} locale - BCP 47 locale string
 * @attr {string} weekend-days - Comma-separated day indices to disable
 *
 * @fires change - Fired when a date is selected. detail: { value: Date | null, isoValue: string | null }
 *
 * @slot label - Custom label for the trigger field
 * @slot prefix - Content before the trigger text (e.g. icon)
 * @slot helper - Custom helper text
 * @slot error - Custom error content
 *
 * @cssprop --date-picker-bg - Calendar background
 * @cssprop --date-picker-border-color - Calendar border color
 * @cssprop --date-picker-radius - Calendar border radius
 * @cssprop --date-picker-day-selected-bg - Background of selected day
 * @cssprop --date-picker-day-today-color - Color of today's date number
 * @cssprop --date-picker-day-outside-opacity - Opacity of days outside visible month
 *
 * @part field - The trigger button/field
 * @part calendar - The floating calendar panel
 * @part header - Calendar header (nav + label)
 * @part grid - Day grid
 * @part day - Individual day cell
 *
 * @example
 * ```html
 * <!-- Single date with constraints -->
 * <sg-date-picker label="Appointment date" name="date" min="2025-01-01" max="2025-12-31" color="primary"></sg-date-picker>
 *
 * <!-- Range selection -->
 * <sg-date-picker label="Date range" selection-mode="range" color="primary"></sg-date-picker>
 *
 * <!-- Outside label, bordered variant -->
 * <sg-date-picker label="Start date" label-placement="outside" variant="bordered"></sg-date-picker>
 * ```
 */
export const DATE_PICKER_TAG = 'sg-date-picker' as const;

define<SgDatePickerProps, SgDatePickerEvents>(DATE_PICKER_TAG, {
  formAssociated: true,
  props: {
    ...themableBundle,
    ...sizableBundle,
    ...disablableBundle,
    ...roundableBundle,
    error: prop.string(),
    fullwidth: prop.bool(false),
    helper: prop.string(),
    label: prop.string(),
    'label-placement': prop.oneOf(['inset', 'outside'] as const, 'inset'),
    locale: prop.string(),
    max: prop.string(),
    min: prop.string(),
    name: prop.string(),
    placeholder: prop.string(),
    required: prop.bool(false),
    value: prop.string(),
    variant: prop.string<'flat' | 'solid' | 'bordered' | 'outline' | 'ghost'>(),
    'weekend-days': prop.json([] as number[]),
  },

  setup(props, { bind, el, emit, onMounted }) {
    // ── Signals ─────────────────────────────────────────────────────────────

    const isOpen = signal(false);

    // ── Selected date: local-override pattern ──────────────────────────────
    // localSelection holds a user-initiated pick (or undefined = no override).
    // selectedDate falls back to the value prop reactively — no setInterval needed.

    const localSelection = signal<Temporal.PlainDate | null | undefined>(undefined);

    const selectedDate = computed(() =>
      localSelection.value !== undefined ? localSelection.value : parseIso(props.value.value),
    );

    // ── Form context ────────────────────────────────────────────────────────

    const formCtx = inject(FORM_CTX);
    const fCtxProps = useFormContext(bind, props, formCtx);

    const isDisabled = fCtxProps.disabled;
    const locale = computed(() => props.locale.value || (typeof navigator !== 'undefined' ? navigator.language : 'en'));

    // ── Date-picker control ─────────────────────────────────────────────────

    const ctrl = createDatePickerControl({
      get locale() {
        return locale.value;
      },
      get max() {
        return parseIso(props.max.value);
      },
      get min() {
        return parseIso(props.min.value);
      },
      onChange(date) {
        localSelection.value = date;
        isOpen.value = false;
        ctrl.setView('day');
        currentView.value = 'day';
        displayYear.value = ctrl.displayYear();
        displayMonth.value = ctrl.displayMonth();
        emit('change', { isoValue: toIsoString(date) });
      },
      get value() {
        return selectedDate.value;
      },
      get weekendDays() {
        return props['weekend-days'].value ?? [];
      },
    });

    // ── Reactive display state ────────────────────────────────────────────
    // Three separate primitive signals — the proven pattern.

    const currentView = signal<DatePickerView>('day');
    const displayYear = signal(ctrl.displayYear());
    const displayMonth = signal(ctrl.displayMonth()); // 1-indexed (Temporal)

    // ── Form value (host is formAssociated) ──────────────────────────────────

    useField<string>({
      disabled: isDisabled,
      toFormValue: (v) => v || null,
      value: computed(() => toIsoString(selectedDate.value) ?? ''),
    });

    const dialogId = `date-picker-${Math.random().toString(36).slice(2, 9)}-calendar`;

    // ── Derived display values ───────────────────────────────────────────────

    const triggerText = computed(() => {
      if (selectedDate.value) return formatDisplayDate(selectedDate.value, locale.value);

      return props.placeholder.value || '';
    });

    const displayMonth_ = computed(() => {
      const d = Temporal.PlainDate.from({ day: 1, month: displayMonth.value, year: displayYear.value });

      return format(d, { intl: { month: 'long' }, locale: locale.value, tz: 'UTC' });
    });

    const displayYear_ = computed(() => String(displayYear.value));
    const displayLabel = computed(() => `${displayMonth_.value} ${displayYear_.value}`);

    const dayCells = computed(() => {
      void displayYear.value;
      void displayMonth.value;
      void selectedDate.value; // re-run when selection changes to refresh isSelected flags

      return ctrl.dayCells();
    });
    const monthCells = computed(() => {
      void displayYear.value;

      return ctrl.monthCells();
    });
    const yearCells = computed(() => {
      void displayYear.value;

      return ctrl.yearCells();
    });
    const weekdayLabels = computed(() => ctrl.weekdayLabels());
    const tabIndex = () => (isDisabled.value ? '-1' : '0');

    // ── Navigation handlers ──────────────────────────────────────────────────
    // In year/month views navigate by year; in day view navigate by month.

    function handlePrev(): void {
      if (currentView.value === 'day') ctrl.prevMonth();
      else ctrl.prevYear();

      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
    }

    function handleNext(): void {
      if (currentView.value === 'day') ctrl.nextMonth();
      else ctrl.nextYear();

      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
    }

    function handleHeaderClick(): void {
      const views: DatePickerView[] = ['day', 'month', 'year'];
      const next = views[(views.indexOf(currentView.value) + 1) % views.length];

      ctrl.setView(next);
      currentView.value = next;
    }

    function handleSelectMonth(month: number): void {
      ctrl.goTo(ctrl.displayYear(), month);
      ctrl.setView('day');
      displayYear.value = ctrl.displayYear();
      displayMonth.value = ctrl.displayMonth();
      currentView.value = 'day';
    }

    function handleSelectYear(year: number): void {
      ctrl.goTo(year, ctrl.displayMonth());
      ctrl.setView('month');
      displayYear.value = ctrl.displayYear();
      currentView.value = 'month';
    }

    function handleSelectDay(isoStr: string): void {
      const date = parseIso(isoStr);

      if (!date) return;

      ctrl.select(date);
    }

    function handleDayKeydown(e: KeyboardEvent): void {
      const cell = e.currentTarget as HTMLElement;
      const grid = cell.closest('.cal-grid-days');

      if (!grid) return;

      const allCells = Array.from(grid.querySelectorAll<HTMLElement>('.cal-cell-day'));
      const idx = allCells.indexOf(cell);

      if (idx === -1) return;

      let target: HTMLElement | undefined;

      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        handleSelectDay(cell.dataset.iso ?? '');

        return;
      } else if (e.key === 'ArrowRight') {
        target = allCells[idx + 1];
      } else if (e.key === 'ArrowLeft') {
        target = allCells[idx - 1];
      } else if (e.key === 'ArrowDown') {
        target = allCells[idx + 7];
      } else if (e.key === 'ArrowUp') {
        target = allCells[idx - 7];
      } else if (e.key === 'Home') {
        target = allCells[Math.floor(idx / 7) * 7];
      } else if (e.key === 'End') {
        target = allCells[Math.floor(idx / 7) * 7 + 6];
      } else {
        return;
      }

      e.preventDefault();
      target?.focus();
    }

    // ── Open / close ─────────────────────────────────────────────────────────

    function openPicker(): void {
      if (isDisabled.value) return;

      isOpen.value = true;
      ctrl.setView('day');
      currentView.value = 'day';

      const sel = selectedDate.value;

      if (sel) {
        ctrl.goTo(sel.year, sel.month);
        displayYear.value = ctrl.displayYear();
        displayMonth.value = ctrl.displayMonth();
      }
    }

    function closePicker(): void {
      isOpen.value = false;
      ctrl.setView('day');
      currentView.value = 'day';
    }

    function handleTriggerClick(): void {
      if (isOpen.value) closePicker();
      else openPicker();
    }

    // ── Keyboard handlers ────────────────────────────────────────────────────

    function handleTriggerKeydown(e: KeyboardEvent): void {
      if (isDisabled.value) return;

      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        handleTriggerClick();
      } else if (e.key === 'Escape' && isOpen.value) {
        e.preventDefault();
        closePicker();
      }
    }

    function handleCalendarKeydown(e: KeyboardEvent): void {
      if (e.key === 'Escape') {
        e.preventDefault();
        closePicker();
      }
    }

    function handleOutsideClick(e: MouseEvent): void {
      if (!isOpen.value) return;

      const path = e.composedPath();

      if (!path.includes(el)) closePicker();
    }

    // ── Lifecycle ────────────────────────────────────────────────────────────

    onMounted(() => {
      document.addEventListener('click', handleOutsideClick, { capture: true });

      return () => {
        document.removeEventListener('click', handleOutsideClick, { capture: true });
      };
    });

    // ── Host bindings ────────────────────────────────────────────────────────

    bind({
      attr: {
        open: () => (isOpen.value ? true : undefined),
      },
    });

    // ── Template ─────────────────────────────────────────────────────────────

    const inputValue = () => triggerText.value;
    const inputLabel = () => props.label.value ?? '';
    const inputPlaceholder = () => props.placeholder.value ?? '';
    const inputLabelPlacement = () => props['label-placement'].value ?? 'inset';
    const inputColor = () => props.color?.value ?? undefined;
    const inputSize = () => fCtxProps.size?.value ?? undefined;
    const inputVariant = () => fCtxProps.variant?.value ?? undefined;
    const inputRounded = () => props.rounded?.value ?? undefined;
    const inputHelper = () => props.helper.value ?? '';
    const inputError = () => props.error.value ?? '';
    const inputDisabled = () => (isDisabled.value ? true : undefined);
    const inputRequired = () => (props.required.value ? true : undefined);
    const inputFullwidth = () => (props.fullwidth.value ? true : undefined);

    return html`
      <!-- Trigger: sg-input in readonly display mode -->
      <sg-input
        class="trigger"
        readonly
        tabindex="${tabIndex}"
        role="combobox"
        aria-haspopup="dialog"
        aria-controls="${dialogId}"
        :aria-expanded="${() => String(isOpen.value)}"
        :aria-disabled="${() => (isDisabled.value ? 'true' : null)}"
        :value="${inputValue}"
        :label="${inputLabel}"
        :placeholder="${inputPlaceholder}"
        :label-placement="${inputLabelPlacement}"
        :color="${inputColor}"
        :size="${inputSize}"
        :variant="${inputVariant}"
        :rounded="${inputRounded}"
        :helper="${inputHelper}"
        :error="${inputError}"
        ?disabled="${inputDisabled}"
        ?required="${inputRequired}"
        ?fullwidth="${inputFullwidth}"
        @click="${handleTriggerClick}"
        @keydown="${handleTriggerKeydown}">
        <sg-icon slot="suffix" name="calendar" size="16" stroke-width="1.75" aria-hidden="true"></sg-icon>
      </sg-input>

      <!-- Calendar popup -->
      <div
        class="calendar"
        id="${dialogId}"
        role="dialog"
        aria-modal="true"
        :aria-label="${() => `Choose date — ${displayLabel.value}`}"
        ?data-open="${isOpen}"
        @keydown="${handleCalendarKeydown}">
        <!-- Calendar header -->
        <div class="cal-header" part="header">
          <button class="nav-btn" type="button" aria-label="Previous" @click="${handlePrev}">
            <sg-icon name="chevron-left" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
          </button>
          <button
            class="cal-label-btn"
            type="button"
            :aria-label="${() =>
              `Switch to ${currentView.value === 'day' ? 'month' : currentView.value === 'month' ? 'year' : 'day'} view`}"
            @click="${handleHeaderClick}">
            <span class="cal-label-month">${displayMonth_}</span><span class="cal-label-sep" aria-hidden="true">/</span
            ><span class="cal-label-year">${displayYear_}</span>
          </button>
          <button class="nav-btn" type="button" aria-label="Next" @click="${handleNext}">
            <sg-icon name="chevron-right" size="16" stroke-width="2" aria-hidden="true"></sg-icon>
          </button>
        </div>

        <!-- Day view: flat 7-column CSS grid, weekday headers + day cells -->
        <div
          class="cal-grid cal-grid-days"
          role="grid"
          part="grid"
          :aria-label="${() => displayLabel.value}"
          ?hidden="${() => currentView.value !== 'day'}">
          ${() =>
            weekdayLabels.value.map(
              (lbl) => html`<div class="cal-cell cal-cell-head" role="columnheader" aria-label="${lbl}">${lbl}</div>`,
            )}
          ${() =>
            dayCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-day"
                  role="gridcell"
                  part="day"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled)}"
                  :aria-current="${() => (cell.isToday ? 'date' : null)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-today="${() => cell.isToday}"
                  ?data-outside="${() => cell.isOutsideMonth}"
                  ?data-disabled="${() => cell.isDisabled}"
                  data-iso="${cell.iso}"
                  tabindex="${() => (cell.isDisabled ? '-1' : '0')}"
                  @click="${() => handleSelectDay(cell.iso)}"
                  @keydown="${handleDayKeydown}">
                  ${String(cell.day)}
                </div>`,
            )}
        </div>

        <!-- Month view -->
        <div
          class="cal-grid cal-grid-months"
          role="grid"
          :aria-label="${() => displayYear_.value}"
          ?hidden="${() => currentView.value !== 'month'}">
          ${() =>
            monthCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-month"
                  role="gridcell"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-disabled="${() => cell.isDisabled}"
                  tabindex="${() => (cell.isDisabled ? '-1' : '0')}"
                  @click="${() => {
                    if (!cell.isDisabled) handleSelectMonth(cell.month);
                  }}"
                  @keydown="${(e: KeyboardEvent) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();

                      if (!cell.isDisabled) handleSelectMonth(cell.month);
                    }
                  }}">
                  ${cell.shortLabel}
                </div>`,
            )}
        </div>

        <!-- Year view -->
        <div
          class="cal-grid cal-grid-years"
          role="grid"
          aria-label="Select year"
          ?hidden="${() => currentView.value !== 'year'}">
          ${() =>
            yearCells.value.map(
              (cell) =>
                html`<div
                  class="cal-cell cal-cell-year"
                  role="gridcell"
                  :aria-selected="${() => String(cell.isSelected)}"
                  :aria-disabled="${() => String(cell.isDisabled)}"
                  ?data-selected="${() => cell.isSelected}"
                  ?data-disabled="${() => cell.isDisabled}"
                  tabindex="${() => (cell.isDisabled ? '-1' : '0')}"
                  @click="${() => {
                    if (!cell.isDisabled) handleSelectYear(cell.year);
                  }}"
                  @keydown="${(e: KeyboardEvent) => {
                    if (e.key === 'Enter' || e.key === ' ') {
                      e.preventDefault();

                      if (!cell.isDisabled) handleSelectYear(cell.year);
                    }
                  }}">
                  ${String(cell.year)}
                </div>`,
            )}
        </div>
      </div>
    `;
  },

  shadow: { delegatesFocus: true },
  styles: [colorThemeMixin, reducedMotionMixin, componentStyles],
});

Basic Usage

PreviewCode
RTL

Listen for the change event to react to selections:

js
document.querySelector('sg-date-picker').addEventListener('change', (e) => {
  console.log(e.detail.isoValue); // '2025-06-15' or null
});

With Min / Max Bounds

Restrict the selectable range with ISO 8601 min and max attributes.

PreviewCode
RTL

Disabling Weekends

Pass a JSON array of day-of-week indices (0 = Sunday … 6 = Saturday) to weekend-days.

PreviewCode
RTL

Locale

Override the display locale with any BCP 47 tag. Day and month names update automatically.

PreviewCode
RTL

Pre-selected Value

Set value to an ISO 8601 date string to initialise the selection.

PreviewCode
RTL

Form Integration

sg-date-picker is form-associated. The submitted value is the ISO 8601 string or empty string when no date is selected.

PreviewCode
RTL

Sizes

PreviewCode
RTL

Variants

PreviewCode
RTL

Colors

PreviewCode
RTL

Error and Helper Text

PreviewCode
RTL

Disabled

PreviewCode
RTL

API

Props

PropTypeDefaultDescription
valuestringSelected date in ISO 8601 format (yyyy-MM-dd)
minstringMinimum selectable date (yyyy-MM-dd, inclusive)
maxstringMaximum selectable date (yyyy-MM-dd, inclusive)
labelstringVisible label
label-placement'inset' | 'outside''inset'Label position
placeholderstringTrigger placeholder when no date selected
namestringForm field name
disabledbooleanfalseDisable the picker
requiredbooleanfalseMark as required
errorstringError message (shown below trigger in error color)
helperstringHelper text (shown below trigger)
localestringbrowser localeBCP 47 locale for day/month names
weekend-daysstringJSON array of day indices to disable (e.g. "[0,6]")
colorstringTheme color (primary, secondary, info, success, warning, error)
sizestring'md'Size variant: sm, md, lg
variantstringVisual variant: flat, solid, bordered, outline, ghost
roundedstringBorder radius override
fullwidthbooleanfalseExpand to full container width

Events

EventDetailDescription
change{ isoValue: string | null }Fired when a date is selected. isoValue is the ISO 8601 date string or null when cleared

CSS Custom Properties

PropertyDescription
--date-picker-bgCalendar panel background
--date-picker-border-colorCalendar panel border
--date-picker-radiusBorder radius of trigger and calendar
--date-picker-shadowCalendar drop shadow
--date-picker-day-selected-bgBackground of the selected day cell
--date-picker-day-today-colorText color of today's date
--date-picker-day-outside-opacityOpacity of days outside the visible month

Parts

PartDescription
fieldThe trigger button / field
calendarThe floating calendar panel
headerCalendar navigation header
gridDay grid (role="grid")
dayIndividual day cell

Accessibility

sg-date-picker follows the ARIA Date Picker Dialog Pattern.

  • The trigger has role="combobox" and aria-haspopup="dialog".
  • The calendar panel has role="dialog" and aria-modal="true".
  • The day grid has role="grid"; each day is a role="gridcell" with aria-selected and aria-disabled.
  • Today's date receives aria-current="date".
  • Weekday column headers use role="columnheader".
  • Escape closes the calendar from any focused element inside it.
  • Arrow keys navigate day cells: ArrowRight/ArrowLeft move by one day, ArrowDown/ArrowUp move by one week, Home/End move to the first/last day of the current row.
  • The trigger aria-expanded attribute reflects open/closed state.
  • Disabled days have tabindex="-1" and pointer-events: none.