dayfold

Headless, accessible, tiny date picker for any framework. Hijri, Persian and every Intl calendar built in — zero dependencies.
169
Install
npm install dayfold
Latest Version:0.1.0
Vue:3
License:MIT
Last Updated:Sep 29, 2026
Links: GitHub  ·  npm
Maintainer: imranaslam240

dayfold

npm minzipped size types license

An accessible, headless date picker for React, Vue and plain JavaScript — with Hijri, Persian and every other Intl calendar built in, in about 7.5 kB and zero dependencies.

dayfold handles the hard parts of a date picker — calendar math, keyboard navigation, ARIA, focus management, screen-reader announcements — and lets you render any markup with any styling. If you'd rather not build the markup, an optional ready-made <DateField> is included.

Live demos & docs · API reference · العربية · GitHub


Contents


Why dayfold

Headless You get state and prop getters for your own elements. Works with Tailwind, CSS Modules, styled-components, any design system — there is nothing to override.
Accessible by default Follows the W3C APG date-picker dialog and grid patterns with a real <button> in every cell, roving focus, polite live announcements, dialog focus trap and RTL-aware arrow keys. Tested with axe-core.
Every calendar, no extra weight Hijri (Umm al-Qura), Persian, Hebrew, Buddhist, Japanese… are computed from the browser's built-in Intl data, so they cost 0 extra bytes and need no add-on package. Show a second calendar under each day (e.g. Hijri under Gregorian).
Every string is yours Every aria-label, announcement and placeholder can be overridden per instance. Arabic ships built in.
Tiny ~7.5 kB min+gzip for the complete picker: single / range / multiple selection, popup, text input, month & year dropdowns. Zero dependencies, tree-shakeable.
Framework-agnostic One core with thin adapters: React 18+, Vue 3.5+, or plain DOM.
Forms & SEO ready Hidden inputs for native <form> posts, <time datetime> props, SSR-safe ids, and a fixed 6-week grid that never shifts layout.
Modern dates Values are plain ISO strings ("2026-09-29") — no time-zone bugs. Accepts Date, ISO strings and Temporal.PlainDate.

Comparison

Measured in September 2026. Sizes are minified + gzipped JavaScript as a consumer bundles it (esbuild / Bundlephobia); styles for the other libraries are extra.

dayfold cally react-day-picker flatpickr react-datepicker
JS size (min+gzip) 7.5 kB 9.2 kB 18.9 kB 14.8 kB 46.7 kB
Dependencies 0 1 2 0 3
Frameworks React, Vue, vanilla Web component React Vanilla React
Headless (your markup) Yes Partly (parts / CSS vars) Partly (class names, components) No No
Input + popup included Yes No No (grid only) Yes Yes
Hijri / Persian / Hebrew Built in (Intl) Not documented Separate @daypicker/* add-ons Not built in Not built in
Month & year dropdowns Yes — Yes Month only Yes
Declarative disabled rules Yes — Yes Yes Yes
Every a11y string overridable Yes Partly Yes Partly Partly
Time picking No (dates only) No No Yes Yes

Where others are stronger: react-day-picker has a larger ecosystem (it powers the shadcn/ui calendar); flatpickr and react-datepicker include time selection, which dayfold does not (yet). If you need a time picker today, pair dayfold with a time input or use one of those.


Installation

npm install dayfold
# or
pnpm add dayfold
# or
yarn add dayfold

React (18+) and Vue (3.5+) are optional peer dependencies — install whichever you use. Nothing else is installed.


Quick start

1. Ready-made field (React)

The fastest way: an accessible, styled field you can still customise.

import { useState } from 'react'
import { DateField } from 'dayfold/react/field'
import 'dayfold/field.css' // optional default look

export function BookingForm() {
  const [date, setDate] = useState<string | null>(null)

  return (
    <DateField
      label="Appointment date"
      value={date}
      onValueChange={setDate}
      disabled={{ before: new Date() }} // no past dates
      iconPosition="start"               // 'start' | 'end'
      name="appointment"                 // posts "2026-09-29" with the form
    />
  )
}

2. Headless React hook

Full control of the markup:

import { useDatePicker } from 'dayfold/react'

export function Calendar() {
  const dp = useDatePicker({ locale: 'en-US' })

  return (
    <div {...dp.getRootProps()}>
      <button {...dp.getPrevButtonProps()}>‹</button>
      <button {...dp.getNextButtonProps()}>›</button>

      {dp.months.map((month) => (
        <table key={month.start} {...dp.getGridProps(month)}>
          <caption {...dp.getMonthLabelProps(month)}>{month.label}</caption>
          <thead>
            <tr>
              {dp.weekdays.map((w) => (
                <th key={w.weekday} {...dp.getWeekdayProps(w)}>{w.narrow}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {month.weeks.map((week) => (
              <tr key={week[0].date} {...dp.getWeekProps()}>
                {week.map((day) => (
                  <td key={day.date} {...dp.getCellProps(day)}>
                    <button {...dp.getDayProps(day)}>{day.label}</button>
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      ))}

      <p>Selected: {dp.valueText ?? 'none'}</p>
    </div>
  )
}

3. Vue

<script setup>
import { useDatePicker } from 'dayfold/vue'

// Options can be an object, a ref, or a getter — changes are picked up reactively.
const dp = useDatePicker({ locale: 'ar-SA', calendar: 'islamic-umalqura' })
</script>

<template>
  <div v-bind="dp.getRootProps()">
    <button v-bind="dp.getPrevButtonProps()">‹</button>
    <button v-bind="dp.getNextButtonProps()">›</button>
    <table v-for="month in dp.months.value" :key="month.start" v-bind="dp.getGridProps(month)">
      <caption v-bind="dp.getMonthLabelProps(month)">{{ month.label }}</caption>
      <tr v-for="week in month.weeks" :key="week[0].date" v-bind="dp.getWeekProps()">
        <td v-for="day in week" :key="day.date" v-bind="dp.getCellProps(day)">
          <button v-bind="dp.getDayProps(day)">{{ day.label }}</button>
        </td>
      </tr>
    </table>
  </div>
</template>

4. Vanilla JavaScript

import { createDatePicker } from 'dayfold'
import { h } from 'dayfold/dom'

const dp = createDatePicker({ locale: 'fa-IR', calendar: 'persian' })
const root = document.querySelector('#calendar')

function render() {
  const [month] = dp.getMonths()
  root.replaceChildren(
    h('table', dp.getGridProps(month),
      h('caption', dp.getMonthLabelProps(month), month.label),
      ...month.weeks.map((week) =>
        h('tr', dp.getWeekProps(),
          ...week.map((day) =>
            h('td', dp.getCellProps(day), h('button', dp.getDayProps(day), day.label)))))),
  )
}

dp.subscribe(render)
render()

Focus is restored to the right day after every render, so a full re-render like this stays keyboard-friendly. Update options later with dp.update({ ...options }).


DateField props

import { DateField } from 'dayfold/react/field' — accepts every picker option plus:

Prop Type Default Description
label ReactNode — (required) Visible label, associated with the input.
hideLabel boolean false Hide the label visually but keep it for screen readers.
description ReactNode — Hint below the field, linked with aria-describedby.
error ReactNode — Error text; sets aria-invalid and is linked with aria-describedby.
icon ReactNode | false calendar icon Content of the trigger button — any SVG, icon-library component, emoji or text. false removes the trigger.
iconPosition 'start' | 'end' 'end' Where the trigger sits. Changes DOM order (so Tab order matches) and mirrors automatically in RTL.
dropdowns boolean true Month and year <select>s in the popup header.
prevIcon / nextIcon ReactNode chevrons Icons for the month navigation buttons.
renderDay (day: CalendarDay) => ReactNode day number Custom day content (the accessible name still comes from labels.day).
inputProps InputHTMLAttributes — Extra input attributes: required, autoFocus, className, …
className string — Added to the root element.
classNames Partial<Record<Part, string>> — Class per part: root, label, description, error, control, input, trigger, popover, header, selects, select, nav, navButton, months, grid, weekday, cell, day.

Every part also has a stable dayfold-* class (e.g. .dayfold-trigger), and the root carries data-icon-position, data-open and data-invalid.

<DateField label="Start date" icon={<CalendarDays size={18} />} iconPosition="start" />
<DateField label="Check-in" icon={<span aria-hidden="true">📅</span>} />
<DateField label="Date of birth" icon={false} placeholder="dd/mm/yyyy" />
<DateField label="Date" classNames={{ input: 'input input-bordered', day: 'btn btn-ghost' }} />

Theming field.css — every value is a custom property; light / dark is automatic:

.my-form .dayfold-field {
  --dayfold-accent: #0f766e;
  --dayfold-on-accent: #fff;
  --dayfold-accent-soft: #ccfbf1;
  --dayfold-bg: #fff;
  --dayfold-fg: #111;
  --dayfold-muted: #555;
  --dayfold-border: #ccc;
  --dayfold-danger: #b42318;
  --dayfold-focus: #0f766e;
  --dayfold-radius: 12px;
  --dayfold-day-size: 44px;
}

Picker options

Used by useDatePicker() (React / Vue), createDatePicker() (vanilla) and <DateField>.

Selection

Option Type Default Description
mode 'single' | 'range' | 'multiple' 'single' Selection mode.
value see below — Controlled value. Pass null (or []) for "nothing selected".
defaultValue see below — Initial value for uncontrolled use.
onValueChange (value, { date, source }) => void — Called on every change. source: 'click' | 'keyboard' | 'input' | 'clear' | 'api'.
maxSelections number — Multiple mode: maximum number of dates.
allowDisabledInRange boolean false Range mode: allow a range to span disabled dates.

Values are always returned as ISO strings: string | null (single), string[] (multiple), { start, end } | null (range). Inputs also accept Date and Temporal.PlainDate.

Restricting dates

Option Type Default Description
min / max date — Earliest / latest selectable date. Also stops navigation and trims the dropdowns.
disabled DateMatcher | DateMatcher[] — Dates that can't be selected — see disabling dates.
isDateDisabled (iso: string) => boolean — Function form (same as a function inside disabled).

Calendar & locale

Option Type Default Description
locale BCP 47 tag runtime locale Formatting, week start, weekend and direction, e.g. 'en-US', 'ar-SA'.
calendar calendar id 'gregory' 'islamic-umalqura', 'islamic-civil', 'persian', 'hebrew', 'buddhist', 'japanese'…
secondaryCalendar calendar id — Second calendar shown under each day.
numberingSystem 'latn' | 'arab' | … from locale Digits to display.
dir 'ltr' | 'rtl' from locale Writing direction (also flips arrow keys).
weekStartsOn 0–6 from locale First day of the week (0 = Sunday).

Display & navigation

Option Type Default Description
numberOfMonths number 1 Months shown side by side.
fixedWeeks boolean true Always 6 rows, so the grid never changes height (no layout shift).
years { from?, to? } min/max, else −100 / +50 Year range for the year dropdown, in the calendar's own numbering.
defaultFocusedDate date selection, then today Initially focused / visible date.
today date local today Override "today" (tests, SSR).

Popup

Option Type Default Description
open / defaultOpen boolean false Controlled / initial popup state.
onOpenChange (open) => void — Called when the popup opens or closes.
closeOnSelect boolean true Close after picking a date (or a full range).
closeOnOutsideClick boolean true Close on pointer-down outside the popup.
modal boolean true Trap Tab inside the popup.

Text input & forms

Option Type Default Description
inputFormat Intl.DateTimeFormatOptions numeric y/m/d How the value is shown in the input.
parse (text, { locale, calendar, today }) => iso | null locale-aware Custom parser. Use withShortcuts() for "tomorrow", "+3d"…
placeholder string e.g. mm/dd/yyyy Input placeholder.
name string — Field name for getHiddenInputProps() / DateField.

Accessibility

Option Type Default Description
labels Partial<Labels> English Override any accessible string — see labels.
announce 'auto' | 'manual' | false 'auto' 'auto' uses a shared live region; 'manual' exposes state.announcement for your own.
id string auto Stable id prefix (pass one for SSR if not using the React / Vue adapters).
focusDay (iso) => void built in Custom focus routine if you don't render data-date attributes.

Returned API

Member Description
months / getMonths() Visible months, each with label, weeks of CalendarDay and more.
weekdays / getWeekdays() Weekday names in display order (narrow, short, long).
value / getValue() Effective value (controlled or not).
valueText / getValueText() Localised text, e.g. "Sep 29 – Oct 3, 2026".
state / getState() focusedDate, visibleDate, open, inputText, inputInvalid, announcement.
monthOptions / yearOptions Entries { value, label, disabled } for dropdowns.
select(iso), clear() Change the selection.
focus(iso) Move the focused / visible date.
goToMonth(n), nextMonth(), prevMonth() Navigate months.
goToYear(year), goToMonthIndex(i) Jump to a year (calendar numbering) or a month of the visible year.
setOpen(bool), toggle(), isOpen() Popup control.
isDateDisabled(iso), hasInvalidValue() Checks against the current options.
getDirection() Resolved 'ltr' / 'rtl'.
subscribe(fn), update(options), destroy() Store helpers for vanilla use.

In React and Vue, months, weekdays, value, valueText, monthOptions and yearOptions are provided directly (in Vue as computed refs).

Prop getters

Each returns a plain object of attributes and handlers — spread it in React, v-bind it in Vue, or apply it with spread() / h() from dayfold/dom.

Getter Put it on Provides
getRootProps() wrapper dir, data-dayfold
getLabelProps() <label> id, htmlFor → input
getInputProps() <input> value, placeholder, aria-invalid, parse on blur / Enter, Alt+↓ opens
getTriggerProps() <button> aria-haspopup, aria-expanded, aria-controls, dynamic aria-label
getDialogProps() popup role="dialog", aria-modal, aria-label, Esc, focus trap
getPrevButtonProps() / getNextButtonProps() <button> aria-label, aria-disabled at min / max
getMonthSelectProps() / getYearSelectProps() <select> aria-label, value, onChange
getClearButtonProps() <button> aria-label, clears the value
getMonthLabelProps(month) heading / caption id that labels the grid
getGridProps(month) <table> role="grid", aria-labelledby, aria-multiselectable
getWeekdayProps(weekday) <th> role="columnheader", full weekday name
getWeekProps() <tr> role="row"
getCellProps(day) <td> role="gridcell", aria-selected, data-* state
getDayProps(day) <button> roving tabIndex, aria-label, aria-current, aria-disabled, keyboard
getLiveRegionProps() element role="status" (with announce: 'manual')
getHiddenInputProps({ name, part }) <input> type="hidden" ISO value for native forms
getTimeProps(date?) <time> dateTime

To add your own handler, call dayfold's too: onClick={(e) => { track(); dp.getDayProps(day).onClick(e) }}.

Calendar data

interface CalendarDay {
  date: string            // '2026-09-29'
  day: number             // day of month in the primary calendar
  label: string           // localised digits, e.g. '٢٩'
  fullLabel: string       // 'Tuesday, September 29, 2026'
  secondaryLabel?: string // day in secondaryCalendar
  secondaryFullLabel?: string
  weekday: number         // 0 = Sunday
  isToday, isSelected, isDisabled, isOutside, isFocused, isWeekend: boolean
  isRangeStart, isRangeEnd, isInRange, isInPreview: boolean
}

Guides

Disabling dates

import { today } from 'dayfold'

useDatePicker({
  disabled: [
    { before: today() },                           // all past dates
    { after: '2027-06-30' },                       // everything after a date
    { from: '2026-12-24', to: '2026-12-26' },      // an inclusive range (either end optional)
    { before: '2026-01-01', after: '2026-12-31' }, // everything outside a window
    '2026-11-11',                                  // one date (string, Date or Temporal)
    { dayOfWeek: [5, 6] },                         // weekdays (0 = Sunday) — here Fri & Sat
    (iso) => holidays.has(iso),                    // any custom rule
  ],
})

Disabled dates stay focusable and are announced as "unavailable", so keyboard and screen-reader users can still explore; ranges can't span them. Use min / max when navigation should stop too.

Linked start & end fields

Options are re-read on every render, so one field can restrict another at runtime:

const [start, setStart] = useState<string | null>(null)
const [end, setEnd] = useState<string | null>(null)

<DateField label="Start date" value={start} onValueChange={setStart}
  disabled={end ? { after: end } : undefined} />
<DateField label="End date" value={end} onValueChange={setEnd}
  disabled={start ? { before: start } : undefined} />
  • A picker opening without a value focuses the nearest selectable day — the end picker opens on the start date.
  • If a value becomes disabled because the other field changed, its input gets aria-invalid="true" and hasInvalidValue() returns true.
  • Vue: useDatePicker(() => ({ disabled: start.value ? { before: start.value } : undefined })). Vanilla: endPicker.update({ ...options, disabled: { before: start } }).

Range and multiple selection

const range = useDatePicker({ mode: 'range', numberOfMonths: 2 })
// range.value → { start: '2026-10-01', end: '2026-10-05' }

const multi = useDatePicker({ mode: 'multiple', maxSelections: 5 })
// multi.value → ['2026-10-01', '2026-10-03']

Days expose isRangeStart, isRangeEnd, isInRange and isInPreview (the tentative range while choosing the end) plus matching data-* attributes.

Month & year dropdowns

<select {...dp.getMonthSelectProps()}>
  {dp.monthOptions.map((m) => <option key={m.value} value={m.value} disabled={m.disabled}>{m.label}</option>)}
</select>
<select {...dp.getYearSelectProps()}>
  {dp.yearOptions.map((y) => <option key={y.value} value={y.value} disabled={y.disabled}>{y.label}</option>)}
</select>

Options are localised and calendar-aware (Hijri years like 1448, 13 months in a Hebrew leap year). Keep the dropdowns mounted across month changes so they keep focus.

Text input & typed shortcuts

import { withShortcuts } from 'dayfold/shortcuts'

const dp = useDatePicker({ locale: 'en-GB', parse: withShortcuts() })

<label {...dp.getLabelProps()}>Departure</label>
<input {...dp.getInputProps()} />
<button {...dp.getTriggerProps()}>📅</button>
{dp.state.open && <div {...dp.getDialogProps()}>{/* calendar */}</div>}

The default parser follows the locale's numeric order (29/09/2026 in en-GB, 09/29/2026 in en-US) and accepts ISO dates and Arabic-Indic digits. withShortcuts() adds today, tomorrow, +3d, -2w, in 1 month, next fri, and Arabic اليوم, غدا, أمس, بعد 3 أيام.

dayfold manages the popup's open state, focus on open, Escape, the focus trap, outside clicks and returning focus to the trigger; positioning is yours (the native popover attribute or CSS anchor positioning work well).

Hijri and other calendars

// Hijri (Umm al-Qura) with Gregorian beneath each day — Arabic digits and RTL come from the locale
useDatePicker({ locale: 'ar-SA', calendar: 'islamic-umalqura', secondaryCalendar: 'gregory' })

// Gregorian with Hijri beneath, Latin digits
useDatePicker({ locale: 'en-SA', secondaryCalendar: 'islamic-umalqura', numberingSystem: 'latn' })

// Persian (Jalali)
useDatePicker({ locale: 'fa-IR', calendar: 'persian' })

Arabic accessible labels:

import { ar } from 'dayfold/locales/ar'
useDatePicker({ locale: 'ar-SA', labels: ar })

Styling

State is exposed as data attributes on both the cell and the day button:

[data-selected] { background: black; color: white; }
[data-today] { font-weight: 700; }
[data-outside] { color: gray; }
[data-disabled] { text-decoration: line-through; }
[data-in-range], [data-preview] { background: #eee; }
[data-range-start] { border-radius: 99px 0 0 99px; }
[data-range-end] { border-radius: 0 99px 99px 0; }
[aria-disabled='true'] { cursor: not-allowed; }

Tailwind: data-[selected]:bg-black data-[outside]:text-gray-400 aria-disabled:cursor-not-allowed.

Forms, SSR & SEO

  • Native forms: <input {...dp.getHiddenInputProps({ name: 'date' })} /> posts the ISO value. Range mode sends start/end, or one input per end with part: 'start' | 'end'.
  • Server rendering: no browser globals are touched at import or render. React and Vue use useId() for hydration-safe ids. Pass locale (and today if needed) so server and client agree. Node and browsers can ship different ICU data, so add suppressHydrationWarning to formatted month names or render the calendar client-only.
  • Machine-readable dates: <time {...dp.getTimeProps()}> renders datetime="2026-09-29".
  • No layout shift: fixedWeeks (default) keeps every month at six rows.

Accessibility

  • W3C APG date-picker dialog and grid patterns, with a real <button> in every cell.
  • One Tab stop for the grid (roving tabindex); disabled dates remain focusable and are announced as unavailable.
  • Month and selection changes are announced in a polite live region.
  • Popup: role="dialog", aria-modal, focus moves in on open, Tab is trapped, Escape closes and returns focus to the trigger.
  • State is never colour-only — it is in the accessible name and in data attributes.
  • Arrow keys follow reading direction in RTL.
  • Verified with axe-core (WCAG 2.2 AA rules) in the test suite.

Labels

Every accessible string can be overridden per instance:

Label Default Used for
dialog "Choose date" Popup name
prevMonth / nextMonth "Previous month" / "Next month" Navigation buttons
monthSelect / yearSelect "Month" / "Year" Dropdowns
clear "Clear" Clear button
today, selected, unavailable, rangeStart, rangeEnd "Today", "selected", … Words inside day labels
trigger(valueText) "Choose date" / "Change date, Sep 29, 2026" Trigger button name
day(day, labels) "Today, Tuesday, September 29, 2026, selected" Full day label
monthChanged(label) the month label Announcement after navigation
selectionChanged(valueText) "Selected Sep 29, 2026" Announcement after a selection
useDatePicker({
  labels: {
    dialog: 'Choose your check-in date',
    day: (d, l) => [d.fullLabel, d.isDisabled && 'fully booked'].filter(Boolean).join(', '),
  },
})

You still own the rest of the page: give the input a visible label, keep text contrast at 4.5:1 and day buttons at least 24×24 px (44 px recommended).

Keyboard

Key Action
← / → Previous / next day (swapped in RTL)
↑ / ↓ Same weekday, previous / next week
Home / End First / last day of the week
Page Up / Page Down Previous / next month
Shift + Page Up / Page Down Previous / next year
Enter / Space Select the focused day
Esc Close the popup and return focus
Tab / Shift + Tab Move between popup controls (trapped when modal)
Alt + ↓ (in input) Open the popup
Enter (in input) Parse and commit the typed date

Browser support

All evergreen browsers: Chrome / Edge 92+, Firefox 90+, Safari 15.4+ (iOS 15.4+). The optional field.css uses light-dark() for automatic dark mode where supported and falls back to the light theme elsewhere. Node 18+ for server rendering and tests.

Entry points & sizes

Import Contents Size (min+gzip)
dayfold createDatePicker() + date utilities 7.5 kB
dayfold/react useDatePicker() (includes core) 7.7 kB
dayfold/vue useDatePicker() (includes core) 7.9 kB
dayfold/react/field <DateField> (includes core) 8.8 kB
dayfold/field.css Optional styles for <DateField> 1.6 kB
dayfold/dom spread(), h() for vanilla JS 0.3 kB
dayfold/shortcuts Typed shortcuts parser 1.8 kB
dayfold/locales/ar Arabic accessible labels < 0.5 kB

Size budgets are enforced in CI — a change that grows a bundle past its budget fails the build.

FAQ

Does it include a time picker? Not yet — dayfold picks dates. Pair it with <input type="time"> or your own time control.

What value format do I get? ISO date strings ("2026-09-29"), never Date objects, so there are no time-zone shifts. Convert with toDate(iso) or Temporal.PlainDate.from(iso).

Does the Hijri calendar match the official Umm al-Qura calendar? It uses the browser's ICU islamic-umalqura data, which is based on the official Umm al-Qura tables.

Can I use it with Next.js / Nuxt? Yes — it is SSR-safe. See Forms, SSR & SEO.

Angular, Svelte, Solid? Use the framework-free core (createDatePicker + subscribe) — the prop getters are plain objects.

License

MIT

Related Packages

@mahlaparvaz/vue-datepicker

Vue 3 datepicker supporting Jalali, Hijri, Gregorian, Chinese calendars with ran...

3,683 3
vue-jalali-datepicker

A lightweight, zero-dependency Persian (Jalali / Shamsi) date and range picker f...

539 0
khorshid

Khorshid — one install for the Jalali & Gregorian date picker: headless core, va...

356
vue-persian-calendar-datepicker

An accessible Vue 3 Persian (Jalali) calendar, date picker, and time picker with...

3,651 0

Version History

Version Released Vue License
0.1.0 >=3.5.0 MIT