khorshid
Khorshid · خورشید
Headless, zero-dependency date picker with first-class Jalali (Persian) and Gregorian calendars.
Khorshid (Persian for sun — fitting for the solar Jalali calendar) separates date-picking logic from rendering. The engine — calendars, locales, selection state, formatting, input masking — lives in a headless core with zero runtime dependencies and zero DOM usage. A polished vanilla-JS UI is built on top of it, and framework adapters are thin layers over the same core.
One install, one API. npm i khorshid gives you the vanilla picker, the React bindings and the Vue bindings — the same option names, the same events and the same values in all three:
| Import | What it is |
|---|---|
khorshid |
The vanilla picker (createDatePicker) plus the whole headless core. No framework code. |
khorshid/react |
React bindings: useDatePicker, <Calendar>, <DatePicker>. |
khorshid/vue |
Vue 3 bindings: useDatePicker, <Calendar>, <DatePicker>. |
khorshid/headless |
The DOM-free core on its own — bring your own UI. |
khorshid/holidays |
The official holidays plugin and its providers (online or offline). |
khorshid/element |
<khorshid-datepicker> — the picker as a framework-free Web Component. |
khorshid/styles.css |
The one stylesheet every layer renders against. |
The scoped packages (@khorshid-js/core, @khorshid-js/dom, @khorshid-js/react, @khorshid-js/vue, @khorshid-js/holidays, @khorshid-js/element) remain published and are what khorshid re-exports — use them directly if you prefer picking pieces.
Features
- Three calendar systems — Jalali (jalaali-js break-table algorithm, matching Iran's official calendar; years −61–3177 AP), Gregorian (the same span: 560-03-20 – 3799-03-19 CE) and lunar Hijri (Umm al-Qura, 1300–1600 AH; a tabular
civilvariant covers 1–3000 AH). Every valid date round-trips, and all three convert losslessly through Julian Day Numbers. - Hijri calendar — the lunar Islamic calendar (Umm al-Qura 1300–1600 AH by default, or the tabular civil variant), with per-month
adjustmentsto match a locally observed calendar. EveryDateValuecarrieshijriparts. - Nine built-in locales — Persian, English, Dari (
fa-AF: حمل، ثور …) and Pashto (ps-AF), across Jalali / Gregorian / Hijri — including the unusual-but-usefulen-US: the Jalali calendar with English transliterated month names (Farvardin, Ordibehesht, …). Custom locales viaregisterLocale. - Shortcuts — "Today", "Last 7 days", "This month" … computed in the active calendar (a Jalali "this month" is Farvardin, not March), plus your own.
- Week, month and year pickers —
unit: 'week' | 'month' | 'year', combinable with range and multiple. - Range limits —
minDays/maxDays(both ends counted), with out-of-limit ends disabled while picking. - Disabled dates with a reason —
{ date, reason }or a predicate returning a string: tooltip, accessible name,error.reason. - Secondary calendar — the Gregorian day under each Jalali day (or any pairing), with the spanned months under the title.
- Natural input —
فردا,۳ روز بعد,جمعه بعد,next friday,+2w,15 March, andA تا Branges. - Native forms —
name/required/formValue: ISO values submitted with the<form>, native validation, reset support. - Web Component —
<khorshid-datepicker>with attributes, properties andsdp-*events. - Offline Iran holidays —
holidaysPlugin({ offline: true }), no network. - Headless architecture —
createPicker()returns plain-JS state, typed events, and precomputed render models (getGrid()→DayCell[][]). Any UI — vanilla, React, Vue — just maps cells to elements. - Single, range, and multiple selection with hover preview for ranges.
- Smart input mask — auto-inserts separators, clamps months/days as you type, understands Persian (۰–۹), Arabic-Indic (٠–٩), and Latin digits, preserves the caret.
- Responsive overlay — anchored popup that flips and shifts to stay in the viewport on desktop; a drag-to-dismiss bottom sheet on small screens.
- Light / dark / auto themes and full CSS-variable theming — restyle everything without touching a class.
- Tasteful animations — month slide, view zoom, selection pop; always disabled under
prefers-reduced-motion. - Keyboard navigation & a11y — roving tabindex, ARIA grid semantics, focus trap in the sheet, localized labels.
- RTL first-class — direction comes from the locale; arrow keys and range edges follow it.
- Multi-month panes, min/max bounds, disabled dates (list or predicate), TypeScript strict, tree-shakeable (the only declared side effect is the stylesheet import).
Feature tour
import { createDatePicker } from 'khorshid';
// Shortcuts + range limits — "This month" is a Jalali month on a Jalali picker.
createDatePicker('#stay', {
locale: 'fa-IR',
mode: 'range',
months: 2,
shortcuts: true, // or ['today', 'last7Days', { id, label, value }]
minDays: 3, // two nights, both ends counted
maxDays: 15,
});
// Week / month / year pickers — the value is the period start + DateValue.end.
createDatePicker('#month', { locale: 'fa-IR', unit: 'month' }); // 'خرداد ۱۴۰۵'
// The Gregorian date under every Jalali day.
createDatePicker('#date', { locale: 'fa-IR', secondaryCalendar: true });
// Free text: فردا · ۳ روز بعد · جمعه بعد · next friday · +2w · "امروز تا جمعه".
createDatePicker('#due', { locale: 'fa-IR', naturalInput: true });
// Disabled days that say why (tooltip, aria-label, error.reason).
createDatePicker('#visit', {
disabledDates: [{ date: '2026-04-10', reason: 'ظرفیت تکمیل است' }, '2026-04-11'],
});
createDatePicker('#delivery', {
disabledDates: (d) => (d.weekday === 5 ? 'جمعهها تعطیل است' : false),
});
// Native forms — submits checkin=2026-04-04 (ISO), blocks submit while empty.
createDatePicker('#checkin', { locale: 'fa-IR', name: 'checkin', required: true });
Hijri, Dari and Pashto:
import { createHijriCalendar, registerCalendar } from 'khorshid';
createDatePicker('#h', { locale: 'fa-IR-hijri' }); // ۱۴۴۷/۱۰/۰۱
createDatePicker('#af', { locale: 'fa-AF' }); // ۱ حمل ۱۴۰۵
registerCalendar(createHijriCalendar({ adjustments: { '1447-09': 29 } })); // match local moon sighting
The Web Component, and offline Iranian holidays:
<script type="module">
import '@khorshid-js/element/define';
</script>
<form>
<khorshid-datepicker name="stay" mode="range" min-days="2" shortcuts required></khorshid-datepicker>
</form>
import { holidaysPlugin } from 'khorshid/holidays';
createDatePicker('#d', { plugins: [holidaysPlugin({ offline: true })] }); // no network
parseNaturalDate(text, { locale, today }), toFormValue(value), core.getShortcuts() / applyShortcut(id) / submitInput(text) / getFormValue() expose the same machinery headlessly. Full guides live in the docs site.
One API, three layers
The vanilla picker, the React bindings and the Vue bindings are not three similar APIs — they are one API with three renderers:
- The same options.
UIOptionsin@khorshid-js/coreis the single list, with a single default table (UI_DEFAULTS).display,readOnly,outsideDays,dayProps,renderFooter,icons,classes,vars, … mean the same thing and default the same way in every layer. - The same events. One
on<Event>callback per core event, generated from the core's event map — nothing is hand-listed per layer. - The same values. A literal
modenarrows the value everywhere, and any value can be handed straight back as input. - The same rendering. All three render the same
sdp*element tree from the same view model (buildPaneModels,dayClassName,rootClassName) against the same stylesheet, and the React/Vue field components use the very overlay shells the vanilla picker uses — popup positioning, drag-to-dismiss sheet, modal, focus trap, animations. React and Vue are not a reduced version.
const shared = { locale: 'fa-IR', mode: 'range', months: 2, display: 'sheet' };
createDatePicker('#field', shared); // vanilla
<DatePicker {...shared} /> // React
<DatePicker v-bind="shared" /> // Vue
Installation
npm install khorshid
Quick start — vanilla
import { createDatePicker } from 'khorshid';
import 'khorshid/styles.css';
const picker = createDatePicker('#birthday', { locale: 'fa-IR' });
picker.on('change', ({ value }) => {
if (value.mode === 'single' && value.date) {
console.log(value.date.iso); // '2026-03-21'
console.log(value.date.formatted); // '۱۴۰۵/۰۱/۰۱'
}
});
Bind to an <input> (or a selector for one) and you get a masked input with a popup — or a bottom sheet on small screens. Pass any other element to mount an always-visible inline calendar:
createDatePicker(document.querySelector('#calendar'), {
mode: 'range',
months: 2,
locale: 'en-US-gregorian',
});
The returned DatePicker exposes open(), close(), toggle(), getValue(), setValue(), clear(), on() / off(), setTheme(), setLocale(), setCalendar(), setMode(), setVars(), destroy() — plus picker.core, the underlying headless instance, and picker.el for the rendered elements.
Where navigation lives — high-level lifecycle (
open/getValue/on/…) is on theDatePickerfacade; imperative calendar navigation (next,prev,goTo,setView,selectMonth, …) lives onpicker.core. The facade stays small on purpose;picker.coreis never a dead end.
modevsdisplay—modeis the selection kind ('single' | 'range' | 'multiple');displayis the overlay kind for input pickers ('auto' | 'popup' | 'sheet' | 'modal'). They're independent.
Typed values
getValue() returns a mode-tagged union. Pass a literal mode and the return
type is narrowed for you — no value.mode === '…' guard needed:
const range = createDatePicker('#dates', { mode: 'range', months: 2 });
range.getValue().start; // ✅ typed { start, end }
const many = createDatePicker('#days', { mode: 'multiple' });
many.getValue().dates; // ✅ typed DateValue[]
The same narrowing works in React and Vue (useDatePicker({ mode: 'range' }).value.start).
For a mode only known at runtime, narrow with the exported guards:
import { isRangeValue } from 'khorshid';
const v = picker.getValue();
if (isRangeValue(v)) useRange(v.start, v.end);
A value round-trips: whatever you get out of getValue() (or an onChange
payload) can go straight back into setValue() / value= with no conversion.
Quick start — React
import { DatePicker, Calendar, useDatePicker } from 'khorshid/react';
import 'khorshid/styles.css';
// A field that opens a popup on desktop and a bottom sheet on mobile.
<DatePicker locale="fa-IR" onChange={({ value }) => console.log(value.date?.iso)} />
// An always-visible panel, two months, range mode.
<Calendar locale="en-US-gregorian" mode="range" months={2} />
// Or headless: own the core, render what you like.
const { value, actions, state } = useDatePicker({ mode: 'range' });
value.start; // ✅ narrowed
Quick start — Vue
<script setup lang="ts">
import { DatePicker } from 'khorshid/vue';
import 'khorshid/styles.css';
import { ref } from 'vue';
const date = ref('2026-03-21');
</script>
<template>
<DatePicker v-model="date" locale="fa-IR" @range-complete="onDone" />
</template>
Quick start — headless (khorshid/headless)
createPicker(options) returns a PickerCore: state + subscribe (a re-render signal) + typed events + derived render models. A UI is just a render loop:
import { createPicker } from 'khorshid/headless';
const picker = createPicker({ locale: 'fa-IR', mode: 'single' });
function render() {
const grid = picker.getGrid(); // CalendarGrid for pane 0
header.textContent = grid.title.label; // e.g. 'فروردین ۱۴۰۵'
renderWeekdayRow(grid.weekdays); // { min, short, long, weekend }
for (const week of grid.weeks) { // DayCell[][] — rows of 7
for (const cell of week) {
// cell.label is already in locale digits; all flags precomputed:
// today, selected, rangeStart, rangeEnd, inRange, preview,
// disabled, outside, weekend, focused
const el = renderDay(cell);
el.onclick = () => picker.select(cell.date);
el.onmouseenter = () => picker.hover(cell.date);
}
}
}
const unsubscribe = picker.subscribe(render); // called after every state change
render();
prevButton.onclick = () => picker.prev();
nextButton.onclick = () => picker.next();
picker.on('change', ({ value, source }) => console.log(value, source));
This is exactly how @khorshid-js/dom, @khorshid-js/react and @khorshid-js/vue are built. In React, the useDatePicker hook wraps it for you:
import { useDatePicker } from 'khorshid/react';
const { core, state, value } = useDatePicker({ mode: 'range' });
// map core.getGrid() to components; call core.select(cell.date) on click
(Rolling your own? Don't hand core.getState straight to useSyncExternalStore: it returns a fresh snapshot per call, which re-renders forever. Cache the snapshot and refresh it in the subscribe callback — usePickerState does exactly that.)
Other useful PickerCore surface: getMonthCells() / getYearCells() / getYearRangeLabel() for the month/year views, setView(), selectMonth() / selectYear(), goTo() / goToToday(), keyboard helpers (focusDate, moveFocus, focusWeekEdge, selectFocused), format() / parse() / handleInput() for text I/O, and toDateValue() to expand any date into the full payload shape.
Built-in locales
| Key | Language | Calendar | Direction | Week starts |
|---|---|---|---|---|
fa-IR |
Persian | Jalali | RTL | Saturday |
en-US |
English | Jalali | LTR | Saturday |
en-US-gregorian |
English | Gregorian | LTR | Sunday |
fa-IR-gregorian |
Persian | Gregorian | RTL | Saturday |
fa-IR-hijri |
Persian | Hijri | RTL | Saturday |
en-US-hijri |
English | Hijri | LTR | Sunday |
fa-AF |
Dari | Jalali (حمل، ثور …) | RTL | Saturday |
fa-AF-gregorian |
Dari | Gregorian (جنوری، فبروری …) | RTL | Saturday |
ps-AF |
Pashto | Jalali (وری، غویی …) | RTL | Saturday |
A locale binds language + calendar + conventions (direction, week start, weekend, digits, month/weekday names, formats, UI labels). Calendar variants are separate locales so month names always match the calendar — and setCalendar('gregorian') on a fa-IR picker automatically switches to fa-IR-gregorian (same language, other calendar), preferring the same region (fa-AF → fa-AF-gregorian). If no variant exists for the current language, an error event with code no-locale-variant fires instead.
Add your own with registerLocale(locale); see the Locale interface in @khorshid-js/core.
Options
PickerOptions (core — accepted everywhere)
| Option | Type | Default | Description |
|---|---|---|---|
locale |
string | Locale |
'fa-IR' |
Locale key or object. Determines the calendar. |
mode |
'single' | 'range' | 'multiple' |
'single' |
Selection mode. |
value |
PickerValueInput |
— | Initial value: a date, an array, or { start, end }. |
min |
DateInput |
— | Earliest selectable date. |
max |
DateInput |
— | Latest selectable date. |
disabledDates |
(DateInput | { date, reason? })[] | (date: PlainDate) => boolean | string |
— | Disable specific dates — a list, or a predicate. A reason becomes the tooltip, accessible name and error.reason. |
unit |
'day' | 'week' | 'month' | 'year' |
'day' |
What one pick selects (week / month / year pickers). |
minDays / maxDays |
number |
— | Range length limits in days, both ends counted (two nights = 3). |
shortcuts |
boolean | (ShortcutPreset | Shortcut)[] |
— | Quick-pick chips, computed in the active calendar. |
secondaryCalendar |
boolean | CalendarId |
— | Show a second calendar's day under each day. true = natural partner. |
naturalInput |
boolean |
false |
Accept free text (فردا, next friday, +3), committed on Enter/blur. |
weekStart |
0–6 |
locale's | Override the locale's first day of week (0 = Sunday … 6 = Saturday). |
months |
number |
1 |
Month panes rendered side by side. |
fixedWeeks |
boolean |
true |
Always render 6 week rows so panel height is stable. |
format |
FormatPattern |
locale.formats.display |
Display format for values and the input. |
autoClose |
boolean |
true |
Close after a completed selection (single: on select; range: on range end). |
time |
boolean | TimeOptions |
— | Enable a time-of-day picker. true = 24-hour HH:mm; an object sets minuteStep, secondStep, showSeconds, use12Hour, defaultTime. |
DateInput is anything date-like: a PlainDate, { year, month, day, calendar? } parts, a native Date, or a Gregorian ISO string 'YYYY-MM-DD'.
Time picker — when time is set, the selected DateValue carries a structured time: { hour, minute, second }, formatted/getInputValue() append it (… HH:mm), and the panel shows a stepper strip below the calendar. Read or drive it imperatively via core.getTime() / core.setTime({ hour, minute }); a time:change event fires on every change (plus change with source: 'time' when a date is selected).
createDatePicker('#dt', { time: { minuteStep: 5, use12Hour: true } });
picker.on('change', ({ value }) => {
if (value.mode === 'single' && value.date?.time) {
const { hour, minute } = value.date.time; // 14, 30
}
});
UI options — the same in every layer
These are UIOptions in the core contract, and they are accepted by the same
names, with the same defaults, everywhere: as createDatePicker options, as
React props, and as Vue props. <Calendar> (a bare panel) takes the panel half
of the list; anything that can open — createDatePicker, <DatePicker> — takes
all of it.
| Option | Type | Default | Description |
|---|---|---|---|
theme |
'light' | 'dark' | 'auto' |
'auto' |
'auto' follows prefers-color-scheme. |
mobileBreakpoint |
number | false |
640 |
Viewport width (px) at/below which the overlay becomes a bottom sheet. false = never. |
container |
HTMLElement |
document.body |
Portal target for the overlay. |
position |
'auto' | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' |
'auto' |
'auto' flips/shifts to stay in the viewport. |
animate |
boolean |
true |
prefers-reduced-motion always wins. |
showToday |
boolean |
true |
Show the Today footer button. |
showClear |
boolean |
true |
Show the Clear footer button. |
confirm |
boolean | 'auto' |
'auto' |
Show a confirm button. 'auto' = only when the panel is a dialog (sheet or modal), re-resolved live when the viewport crosses mobileBreakpoint. |
display |
'auto' | 'inline' | 'popup' | 'sheet' | 'modal' |
'auto' |
The form the picker takes. 'auto' = inline on a plain element, popup on a field (bottom sheet below mobileBreakpoint). 'inline' keeps the field and mounts an always-open panel after it; 'popup' | 'sheet' | 'modal' force that overlay — including on a plain element, which then anchors it and toggles on click. |
readOnly |
boolean |
false |
Show the value but reject all user changes. The input isn't editable and never opens; an inline panel renders non-interactive (.sdp--readonly). |
inputMask |
boolean |
true |
Smart mask on the bound input (single mode only). |
classes |
Partial<Record<SlotName, string>> |
— | Extra class names appended per slot (root, header, day, …). |
renderDay |
(cell: DayCell) => Node |
— | Replace a day cell's content; return nothing for the default. The node type is the layer's own: an HTML string/element in vanilla, a ReactNode, a VNodeChild. |
renderWeekday |
(label: WeekdayLabel) => Node |
— | Replace a weekday header's content. To change only the text, prefer core's weekdays. |
vars |
Record<string, string> |
— | Per-instance theme variables, applied to the panel and the portaled overlay. Bare keys gain the --sdp- prefix. |
icons |
{ prev?, next? } |
chevrons | Custom nav icons. Each is an HTML string, an HTMLElement, or (dir) => string | HTMLElement. |
dayProps |
(cell: DayCell) => { className?, title?, attrs? } |
— | Per-day styling hook — add classes/tooltip/attributes. (Disable days via disabledDates.) |
renderFooter |
(ctx: { value, view }) => string | HTMLElement | null |
— | Render custom content at the bottom of the panel (.sdp__note). |
outsideDays |
'interactive' | 'visible' | 'hidden' |
'interactive' |
Prev/next-month cells: selectable + navigate (default), greyed & inert, or blank. In multi-month views, boundary dates owned by an adjacent pane are always blanked (no duplicates). |
highlightToday |
boolean |
true |
Mark today's cell (a ring in the default look). false keeps the .sdp__day--today class — custom CSS, plugin marks and the roving-focus fallback still use it — and drops only the built-in affordance (.sdp--no-today on the panel). |
yearPicker |
boolean |
true |
Let the header's year open the year view. false renders the year as plain text (.sdp__title-label--year) instead of a button; the month drill-in is untouched and core.setView('years') still works. |
zIndex |
number |
1000 |
Overlay z-index. |
name |
string |
— | Submit the value with the surrounding <form> (a hidden input.sdp-form-value). |
required |
boolean |
false |
Native validation: block submit until a value is chosen. |
formValue |
(value: PickerValue) => string |
toFormValue |
Form serialization. Default ISO YYYY-MM-DD (+ THH:mm:ss), range start/end, multiple comma-joined. |
Plus one callback per event — see below.
Events
Three ways to reach the same event, derived from one list in the core — so every layer exposes every event, and a new event reaches all of them at once:
picker.on('range:complete', onDone); // vanilla: .on()
createDatePicker('#d', { onRangeComplete: onDone }); // vanilla: an option
<DatePicker onRangeComplete={onDone} /> {/* React: a prop */}
<DatePicker @range-complete="onDone" /> <!-- Vue: an emit -->
The name is mechanical: 'range:complete' → onRangeComplete → @range-complete.
Every handler receives the core's own payload object (so onChange gets
{ value, source }, not two arguments). One extra callback that is not a core
event, onOpenChange(open: boolean), merges open/close for controlled UIs.
Subscribe imperatively with picker.on(event, handler); once and onAny (catch-all) are also available on the core. Every date in a payload is a DateValue — the same date in every useful shape, so you never convert anything yourself.
| Event | Payload | Fires when |
|---|---|---|
change |
{ value: PickerValue, source: 'select' | 'input' | 'api' | 'clear' | 'time' | 'shortcut' } |
Every committed value change. |
select |
{ date: DateValue } |
A single day was picked (any mode). |
clear |
{ previous: PickerValue } |
The value was cleared. |
range:start |
{ start: DateValue } |
Range start picked. |
range:complete |
{ start: DateValue, end: DateValue } |
Range end picked. |
open / close |
{} |
Picker opened / closed. |
view:change |
{ view, previous } |
Switched between days / months / years. |
month:change |
{ year, month, direction: 1 | -1 | 0 } |
Displayed month changed — on every path (nav buttons, goTo, setValue, keyboard paging); 0 = jump. |
year:change |
{ year, previous } |
Displayed year changed — likewise on every path. |
locale:change |
{ locale: Locale } |
Locale switched. |
calendar:change |
{ calendar: 'jalali' | 'gregorian' | 'hijri' } |
Calendar switched. |
time:change |
{ time } |
Time of day changed (time picker on). |
shortcut |
{ id, value } |
A shortcut was applied (its change follows with source 'shortcut'). |
input |
{ text, complete, valid } |
Every masked input keystroke. |
error |
{ code, message, input?, reason? } |
invalid-input, out-of-range, disabled (+ reason), range-length, or no-locale-variant. |
A real change payload (mode single, locale fa-IR, Nowruz 1405 selected):
picker.on('change', ({ value, source }) => {
// value.date is a DateValue:
// {
// jalali: { year: 1405, month: 1, day: 1 },
// gregorian: { year: 2026, month: 3, day: 21 },
// date: Date, // native Date at local midnight
// iso: '2026-03-21', // Gregorian ISO
// jdn: 2461121, // Julian Day Number
// weekday: 6, // 0 = Sunday … 6 = Saturday
// formatted: '۱۴۰۵/۰۱/۰۱', // active locale + display format
// }
});
In range mode the value is { mode: 'range', start, end }; in multiple it is { mode: 'multiple', dates }.
Theming
All colors, sizing, radii, and timing are CSS custom properties. The stylesheet reads every token through a private two-tier indirection (--_x: var(--sdp-x, <default>)), so setting a --sdp-* variable on any ancestor — :root, a wrapping element, .sdp itself, or inline — always wins over the built-in default. No class overrides needed:
:root {
--sdp-accent: #0ea5e9;
--sdp-on-accent: #ffffff;
--sdp-radius: 12px;
--sdp-cell-size: 44px;
}
The --sdp-* names are the public API; --_* names are private and may change.
Popup and sheet overlays are portaled to document.body (or the container option), so they do not inherit variables set on the input's ancestors. For per-instance theming, pass the vars option (bare keys get the --sdp- prefix automatically) or call setVars() at runtime — both apply the variables inline to the panel and every mounted overlay:
const picker = createDatePicker('#date', { vars: { accent: '#e11d48', 'on-accent': '#fff' } });
picker.setVars({ '--sdp-radius': '8px' }); // merges with existing vars
picker.setVars({ accent: '' }); // empty string removes one
| Variable | Light default | Dark default |
|---|---|---|
--sdp-accent |
#f5b400 |
#facc15 |
--sdp-on-accent |
#1f1600 |
#1a1400 |
--sdp-accent-strong |
color-mix(in srgb, <accent> 58%, #000) |
<accent> |
--sdp-accent-soft |
color-mix(in srgb, <accent> 28%, transparent) |
color-mix(in srgb, <accent> 20%, transparent) |
--sdp-bg |
#fffdf8 |
#18150f |
--sdp-surface |
#f6f2e9 |
#262119 |
--sdp-text |
#1c1917 |
#f7f4ee |
--sdp-muted |
#78716c |
#a8a29e |
--sdp-border |
#e8e2d6 |
#3b3428 |
--sdp-shadow |
0 12px 40px rgba(28,25,23,.14), 0 2px 8px rgba(28,25,23,.06) |
deeper |
--sdp-radius |
16px |
— |
--sdp-day-radius |
10px |
— |
--sdp-day-gap |
3px |
— |
--sdp-cell-size |
40px |
— |
--sdp-font |
system-ui, …, 'Vazirmatn', Tahoma, sans-serif |
— |
--sdp-font-size |
14px |
— |
--sdp-duration |
200ms |
— |
--sdp-duration-slow |
420ms |
— |
--sdp-ease |
cubic-bezier(.22, 1, .36, 1) |
— |
--sdp-spring |
damped spring (linear(); cubic fallback) |
— |
--sdp-z |
1000 |
— |
--sdp-backdrop |
rgba(0,0,0,.32) |
rgba(0,0,0,.55) |
Dark mode activates two ways: explicitly via theme: 'dark' (sets data-theme="dark"), or with theme: 'auto' following prefers-color-scheme. Class names are stable BEM with an sdp prefix (.sdp__day--selected, .sdp__nav--prev, …) — see packages/dom/CONTRACT.md for the full DOM/CSS contract.
Input mask
Bound inputs (single mode) get a smart mask, driven by the pure function maskDateInput(raw, { locale, format?, calendar?, caret?, previous? }) from @khorshid-js/core:
- Type digits only — separators are inserted automatically following the format (numeric tokens
YYYYMMDDMD; default is the locale's display format). Typing a separator yourself completes a month/day group early (zero-padding it); separators typed before the year group is complete are ignored. The mask never throws, whatever you type or paste. - Persian (۰–۹), Arabic-Indic (٠–٩), and Latin digits are all accepted; the masked text renders in the locale's digits.
- Impossible values are clamped as you type — months to 1–12, days to the month's length in the active calendar (Esfand 30 in leap years, etc.).
- Deletion-aware and caret-preserving, so editing mid-value behaves naturally.
- When the text is complete and forms a valid, allowed date, the selection commits (
changewith source'input'). Violations emiterror(invalid-input/out-of-range/disabled) and the input gets.sdp-input--invalid. - On blur the text normalizes to the canonical formatted value.
- In
range/multiplemodes the input is read-only (it shows the formatted value and opens the picker).
Keyboard
Days view:
| Key | Action |
|---|---|
← / → |
Previous / next day — flipped in RTL locales |
↑ / ↓ |
Previous / next week |
PageUp / PageDown |
Previous / next month |
Shift+PageUp / Shift+PageDown |
Previous / next year |
Home / End |
Start / end of week |
Enter / Space |
Select the focused day |
Escape |
Close the picker |
Months and years views: arrows move by ±1 / ±3 (3-column grid), Enter selects.
Accessibility: overlay panels are role="dialog" (aria-modal in sheet mode), the day grid uses ARIA grid roles with a roving tabindex and weekday column headers (role="columnheader") inside the grid, each day has a full-date aria-label, month/year changes are announced through a visually-hidden aria-live region, nav buttons use localized labels (prevMonth/nextMonth in the days view, prevYear/nextYear in the months/years views), focus is trapped inside the bottom sheet, and every animation respects prefers-reduced-motion.
Format tokens
Used by format, display formats, and parsing (mask patterns use the numeric tokens only).
| Token | Meaning | Example (en-US, 1405/01/01) |
|---|---|---|
YYYY |
4-digit year | 1405 |
YY |
2-digit year | 05 |
MMMM |
Full month name | Farvardin |
MMM |
Short month name | Far |
MM |
2-digit month | 01 |
M |
Month | 1 |
DD |
2-digit day | 01 |
D |
Day | 1 |
dddd |
Full weekday name | Saturday |
ddd |
Short weekday | Sat |
dd |
Min weekday | Sa |
[…] |
Literal text | [Today:] YYYY/MM/DD |
Digits render in the locale's digit set (۱۴۰۵ for fa-IR). Parsing is separator-tolerant (/, -, ., space) and normalizes Persian/Arabic-Indic digits first.
PlainDate
The immutable, calendar-aware date value used throughout (no time, no timezone — the canonical representation is the Julian Day Number, so dates from different calendars compare and convert losslessly):
import { PlainDate } from '@khorshid-js/core';
const nowruz = new PlainDate('jalali', 1405, 1, 1);
nowruz.to('gregorian').toParts(); // { year: 2026, month: 3, day: 21 }
nowruz.toISO(); // '2026-03-21' (ISO is always Gregorian)
nowruz.jdn; // 2461121
nowruz.weekday; // 6 (0 = Sunday … 6 = Saturday)
nowruz.daysInMonth; // 31
nowruz.add({ months: 6 }); // 1405/07/01 — calendar-aware, day-clamping
nowruz.with({ day: 15 }); // 1405/01/15
nowruz.startOfMonth(); nowruz.endOfMonth();
nowruz.isBefore(other); nowruz.isAfter(other); nowruz.equals(other);
nowruz.isBetween(a, b); nowruz.isSameMonth(other); PlainDate.compare(a, b);
PlainDate.today('jalali'); // today in any calendar
PlainDate.from('2026-03-21'); // ISO string (Gregorian)
PlainDate.from(new Date()); // native Date (local Y/M/D)
PlainDate.from({ year: 1405, month: 1, day: 1, calendar: 'jalali' });
nowruz.format('dddd D MMMM YYYY', faIR); // 'شنبه ۱ فروردین ۱۴۰۵'
Lower-level building blocks are exported too: the gregorian / jalali calendar systems (toJDN, fromJDN, daysInMonth, isLeapYear, isValid), registerCalendar for custom systems, formatDate / parseDate / createDateValue, digit utilities (toLocaleDigits, toLatinDigits, normalizeDigits), and the typed Emitter.
Development
npm install # workspace root (Node >= 18)
npm run build # build all packages
npm test # vitest unit tests
npm run typecheck
packages/
core/ @khorshid-js/core — headless engine + the shared UI contract. NO DOM APIs.
dom/ @khorshid-js/dom — vanilla UI + styles/khorshid.css + the overlay shells.
react/ @khorshid-js/react — React bindings over the same contract.
vue/ @khorshid-js/vue — Vue 3 bindings over the same contract.
holidays/ @khorshid-js/holidays — the official holidays plugin.
element/ @khorshid-js/element — <khorshid-datepicker> / <khorshid-date-field> custom elements.
khorshid/ khorshid — the one-install umbrella (re-exports, subpath entries).
Contracts, in the order to change them:
packages/core/src/types.ts— the whole API surface, includingUIOptions(the one option list),EventHandlers(generated from the event map) andValueFor<M>.packages/core/src/picker/ui.tsholds the runtime half (UI_DEFAULTS,PICKER_EVENTS,bindEventHandlers), andpicker/view-model.tsthe shared render model every layer draws from.packages/dom/CONTRACT.md— DOM structure, class names, CSS variables, animations and the keyboard map, which React and Vue render identically.- The implementations.
Design notes live in ARCHITECTURE.md.
License
MIT
Related Packages
Idiomatic Vue 3 bindings for Khorshid — composables + styled components for the...
Vue bindings for jalali-js: useCalendar composable, headless Calendar grid, a de...
A lightweight, zero-dependency Persian (Jalali / Shamsi) date and range picker f...
Idiomatic Vue 3 bindings for Doran — v-model components over the @doranjs/wc eng...
An accessible Vue 3 Persian (Jalali) calendar, date picker, and time picker with...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.0 | >=3.4.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |