vue-jalali-datepicker

A lightweight, zero-dependency Persian (Jalali / Shamsi) date and range picker for Vue 3.
539
Install
npm install vue-jalali-datepicker
Latest Version:0.2.0
Vue:3
License:MIT
Last Updated:Oct 12, 2026
Links: GitHub  ·  npm
Maintainer: aria.aghakhani

📅 vue-jalali-datepicker

A lightweight, zero-dependency Persian (Jalali / Shamsi) date & range picker for Vue 3.

RTL-first · Fully typed · Themeable with CSS variables · ~6 kB gzipped

npm version npm downloads bundle size CI license Vue 3


Two-month Jalali range picker with a selected range

Table of contents


Why this picker?

🪶 Zero dependencies Only vue as a peer dependency. No moment, no dayjs, no UI framework.
📅 Single & range Pick one date or a start/end range with a live hover preview.
🪟 Popover or inline Ships with a ready-made input + popover, or drop the calendar straight into your layout.
📱 Responsive by container The two-month view collapses to one month based on the space it's given (CSS container queries), not the screen size.
🚫 Flexible disabling minDate, maxDate, a list of disabledDates, or any isDateDisabled(date) predicate.
🌐 Localizable Persian and English labels included, every string overridable, Persian or Latin digits.
↔️ RTL first, LTR ready Built for Persian interfaces, works just as well in LTR apps.
🎨 Themeable One color prop to brand it, or every color, radius and shadow as a CSS custom property. A dark theme is built in.
🧮 Exact conversion Closed-form Jalali ↔ Gregorian arithmetic, verified day by day against Intl. Doesn't depend on the browser's Persian calendar support.
🟦 TypeScript Written in TypeScript, with full types for props, events and utilities.
⚡ SSR friendly No DOM access during render. Works in Nuxt and other SSR setups.

Gallery

Popover variant
Popover · input + floating calendar with confirm
Dark theme range picker
Dark theme · range selection
Single month with disabled dates
Single month · disabled dates
LTR English labels with Latin digits
LTR + English labels · Latin digits, Fridays disabled

Installation

# npm
npm install vue-jalali-datepicker

# pnpm
pnpm add vue-jalali-datepicker

# yarn
yarn add vue-jalali-datepicker

# bun
bun add vue-jalali-datepicker

Requirements: Vue 3.3 or newer.

Don't forget the stylesheet. It's shipped separately so you decide where it goes in your CSS cascade:

import 'vue-jalali-datepicker/style.css'

Quick start

Local registration

<script setup lang="ts">
import { ref } from 'vue'
import { JalaliDatePicker } from 'vue-jalali-datepicker'
import 'vue-jalali-datepicker/style.css'

const date = ref<Date | null>(null)
</script>

<template>
  <JalaliDatePicker v-model="date" />
</template>

Global registration

// main.ts
import { createApp } from 'vue'
import { JalaliDatePickerPlugin } from 'vue-jalali-datepicker'
import 'vue-jalali-datepicker/style.css'
import App from './App.vue'

createApp(App)
  .use(JalaliDatePickerPlugin)          // registers <JalaliDatePicker />
  // .use(JalaliDatePickerPlugin, { name: 'DatePicker' })  // or under a custom name
  .mount('#app')

Nuxt 3 / 4

// plugins/jalali-datepicker.ts
import { JalaliDatePickerPlugin } from 'vue-jalali-datepicker'
import 'vue-jalali-datepicker/style.css'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(JalaliDatePickerPlugin)
})

Examples

Date range

In range mode v-model is an object with start and end.

<script setup lang="ts">
import { ref } from 'vue'
import { JalaliDatePicker, type DateRange } from 'vue-jalali-datepicker'

const stay = ref<DateRange>({ start: null, end: null })
</script>

<template>
  <JalaliDatePicker v-model="stay" range />
</template>

Clicking a date earlier than the start flips the range for you, and hovering shows a preview of the range before the second click.

Inline calendar

<JalaliDatePicker v-model="date" variant="inline" />

Inline pickers update v-model on every click. There is no confirm step.

Apply without a confirm button

By default the popover keeps a draft until the user presses تایید (confirm). With auto-apply the value is committed, and the popover closes, as soon as the selection is complete:

<JalaliDatePicker v-model="stay" range auto-apply />

Limiting selectable dates

<script setup lang="ts">
const today = new Date()
const inThreeMonths = new Date(today.getFullYear(), today.getMonth() + 3, today.getDate())

const holidays = [new Date(2026, 2, 20), new Date(2026, 2, 21)]

// Disable every Friday
const isFriday = (date: Date) => date.getDay() === 5
</script>

<template>
  <JalaliDatePicker
    v-model="date"
    :min-date="today"
    :max-date="inThreeMonths"
    :disabled-dates="holidays"
    :is-date-disabled="isFriday"
  />
</template>

All four options can be combined. A day is disabled if any of them says so.

One month only

<JalaliDatePicker v-model="date" :months="1" />

English labels, Latin digits, LTR layout

<script setup lang="ts">
import { JalaliDatePicker, enLocale } from 'vue-jalali-datepicker'
</script>

<template>
  <JalaliDatePicker v-model="date" :locale="enLocale" digits="latin" :rtl="false" />
</template>

Brand color

<JalaliDatePicker v-model="date" color="#2563eb" />

Any CSS color works. See The color prop for details.

Custom label format

<script setup lang="ts">
import { toFaDigits, faLocale, type JalaliDate } from 'vue-jalali-datepicker'

// e.g. "۱۴۰۵/۰۷/۱۸"
const formatDate = (_date: Date, j: JalaliDate) =>
  toFaDigits(`${j.jy}/${String(j.jm).padStart(2, '0')}/${String(j.jd).padStart(2, '0')}`)
</script>

<template>
  <JalaliDatePicker v-model="date" :format-date="formatDate" />
</template>

Custom trigger

Replace the built-in input and button with any element through the trigger slot:

<JalaliDatePicker v-model="date">
  <template #trigger="{ toggle, open, label, placeholder }">
    <button type="button" class="my-button" :aria-expanded="open" @click="toggle">
      📅 {{ label ?? placeholder }}
    </button>
  </template>
</JalaliDatePicker>

Controlling the popover from code

<script setup lang="ts">
import { ref } from 'vue'
import { JalaliDatePicker } from 'vue-jalali-datepicker'

const picker = ref<InstanceType<typeof JalaliDatePicker>>()
</script>

<template>
  <JalaliDatePicker ref="picker" v-model="date" />
  <button @click="picker?.open()">Pick a date</button>
</template>

Inside a form

The built-in input is readonly and accepts a name. Send whatever format your backend expects through a hidden field:

<form @submit.prevent="submit">
  <JalaliDatePicker v-model="birthday" name="birthday-label" />
  <input type="hidden" name="birthday" :value="birthday?.toISOString() ?? ''" />
</form>

Reacting to every click

select-date fires on every day click, even while a popover selection is still a draft. This is useful for analytics or for loading availability:

<JalaliDatePicker v-model="date" @select-date="(d) => loadSlots(d)" />

API reference

Props

Prop Type Default Description
modelValue Date | null | DateRange null Selected value. Date | null in single mode, { start, end } in range mode.
range boolean false Enables start/end range selection.
variant 'popover' | 'inline' 'popover' popover renders an input with a floating calendar. inline renders only the calendar.
months 1 | 2 2 Months displayed side by side. With 2, the second month hides automatically when the calendar has less than ~560px.
placeholder string from locale Placeholder of the built-in input.
minDate Date | null null Earliest selectable day (inclusive).
maxDate Date | null null Latest selectable day (inclusive).
disabledDates Date[] [] Specific days that can't be selected. Only the calendar day matters, not the time.
isDateDisabled (date: Date) => boolean — Predicate for custom rules (weekends, fully-booked days, …).
locale Partial<DatePickerLocale> faLocale Override any label. Missing keys fall back to Persian.
digits 'fa' | 'latin' 'fa' Render numbers as ۱۲۳ or 123.
rtl boolean true Layout direction of the calendar.
autoApply boolean false Popover only. Commit and close as soon as the selection is complete. Hides the confirm button.
showToday boolean true Show the "Today" button in the footer.
showFooter boolean true Inline only. Show the footer with the selection summary and the "Today" button.
align 'start' | 'end' 'start' Popover alignment relative to the trigger (logical, so it follows dir).
disabled boolean false Disables the built-in trigger.
name string — name attribute for the built-in input.
color string | Partial<DatePickerColors> — Accent color. A CSS color string derives every shade automatically; an object sets individual shades. See The color prop.
formatDate (date: Date, jalali: JalaliDate) => string — Custom label for selected dates. Defaults to <day> <month>, e.g. ۱۸ مهر.

Events

Event Payload Fired when
update:modelValue DatePickerValue The value is committed: on every click for inline, on confirm (or auto-apply) for popover.
confirm DatePickerValue The user confirms a popover selection.
select-date Date Any day is clicked, or "Today" is pressed, whether or not the value is committed yet.
open — The popover opens.
close — The popover closes (confirm, outside click, Esc, or close()).

Slots

Slot Props Description
trigger { open: boolean, toggle: () => void, label: string | null, placeholder: string } Replaces the built-in input and button of the popover variant. label is the formatted committed value, or null when nothing is selected.

Exposed methods

Available through a template ref:

Method Description
open() Opens the popover. The draft resets to the current modelValue.
close() Closes the popover and discards the draft.
toggle() Toggles the popover (no-op when disabled).

Types

import type {
  DateRange,        // { start: Date | null; end: Date | null }
  DatePickerValue,  // Date | null | DateRange
  DatePickerLocale, // all translatable strings (see below)
  DatePickerColor,  // string | Partial<DatePickerColors>
  DatePickerColors, // { accent, accentHover, accentSoft, accentFg, onAccent }
  JalaliDate,       // { jy: number; jm: number; jd: number }
  JalaliMonth,      // { jy: number; jm: number }
} from 'vue-jalali-datepicker'

Localization

Two locales are included:

import { faLocale, enLocale } from 'vue-jalali-datepicker'

Pass a full locale or override only what you need. Missing keys fall back to faLocale:

<JalaliDatePicker
  v-model="date"
  :locale="{ confirm: 'ثبت', today: 'برو به امروز', placeholder: 'تاریخ نوبت' }"
/>
All locale keys
Key Persian default English (enLocale)
months ['فروردین', …, 'اسفند'] ['Farvardin', …, 'Esfand']
weekdays ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] ['Sa', 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr']
toggleLabel باز کردن تقویم Open calendar
placeholder تاریخ را انتخاب کنید Select a date
rangePlaceholder بازه تاریخ را انتخاب کنید Select a date range
emptySummary تاریخ را انتخاب کنید Select a date
emptyRangeSummary تاریخ شروع و پایان را انتخاب کنید Select start and end dates
today امروز Today
confirm تایید Confirm
prevMonth ماه قبل Previous month
nextMonth ماه بعد Next month
rangeSeparator ' - ' ' – '

weekdays always starts on Saturday, following the Persian week.

With vue-i18n, build the locale from your messages:

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
const pickerLocale = computed(() => ({ today: t('picker.today'), confirm: t('picker.confirm') }))
</script>

<template>
  <JalaliDatePicker v-model="date" :locale="pickerLocale" />
</template>

Theming

The default look is a soft "rose" palette. There are two ways to change it:

  1. The color prop, the quickest way to match your brand.
  2. CSS custom properties, for full control over every color, radius and shadow.

The color prop

Pass any CSS color and the picker works out the rest:

<JalaliDatePicker v-model="date" color="#2563eb" />
<JalaliDatePicker v-model="range" range color="rgb(22 163 74)" />
<JalaliDatePicker v-model="date" color="var(--brand-primary)" />

From that one color it derives:

Shade How it's derived
Selected day, confirm button the color itself
Hover on the selected day mixed 15% toward the text color
Days inside a range 18% of the color over the surface
Today's number, text inside a range mixed 45% toward the text color, for contrast
Hover on regular days 8% tint of the surface
Text on selected days white or near-black, whichever has the higher WCAG contrast

Because the shades are mixed against the surface and text colors, they adapt automatically to dark mode.

Note: text on selected days is picked automatically for hex (#2563eb, #25e, #2563ebcc) and rgb() / rgba() colors. For other formats (named colors, hsl(), var()…) it stays at the theme default (white). Use the object form to set it.

To control each shade yourself, pass an object instead. Any key you leave out keeps the theme value:

<JalaliDatePicker
  v-model="date"
  :color="{
    accent: '#0f766e',
    accentHover: '#115e59',
    accentSoft: '#ccfbf1',
    accentFg: '#115e59',
    onAccent: '#ffffff',
  }"
/>

The prop is reactive, so you can switch colors at runtime (for example, a per-user theme). It sets the variables inline on the component, so it takes precedence over the CSS variables below for the shades it covers.

CSS variables

Everything is driven by CSS custom properties defined on .vjdp-calendar and .vjdp-popover, so you can retheme it from any ancestor without fighting specificity.

/* Brand it once, app-wide */
.vjdp-calendar,
.vjdp-popover {
  --vjdp-accent: #2563eb;
  --vjdp-accent-hover: #1d4ed8;
  --vjdp-accent-soft: #dbeafe;
  --vjdp-accent-fg: #1e40af;
  --vjdp-radius: 12px;
  --vjdp-font-family: 'Vazirmatn', sans-serif;
}

/* …or scope a variant to one part of your app */
.checkout .vjdp-calendar,
.checkout .vjdp-popover {
  --vjdp-accent: #16a34a;
}
CSS variables
Variable Default (light) Purpose
--vjdp-font-family inherit Font of the whole component
--vjdp-bg #ffffff Surface background
--vjdp-bg-hover #f5efe8 Hover background of days and buttons
--vjdp-fg #2a1f1e Primary text
--vjdp-fg-muted #7a6b67 Secondary text (summary, nav icons)
--vjdp-fg-subtle #a3948f Weekday names, placeholder
--vjdp-border #e8dfd6 Surface, input and button borders
--vjdp-border-faint #f1eae3 Footer divider
--vjdp-accent #d08b7c Selected day, primary button
--vjdp-accent-hover #c77669 Hover state of the accent
--vjdp-accent-soft #f4ddd6 Days inside a range
--vjdp-accent-fg #8e5749 Today's number, text inside a range
--vjdp-on-accent #ffffff Text on the accent color
--vjdp-radius 16px Calendar corner radius
--vjdp-radius-control 10px Input and button radius
--vjdp-shadow subtle Inline calendar shadow
--vjdp-shadow-elevated soft drop Popover shadow
--vjdp-width 640px / 320px Calendar width with two / one month(s)
--vjdp-z-index 50 Popover stacking order
--vjdp-transition 150ms ease Hover transitions (0s under prefers-reduced-motion)

Dark mode

Dark colors switch on when an ancestor has the dark class (the convention used by Tailwind, Nuxt Color Mode, VueUse useDark, …) or the vjdp-dark class:

<html class="dark"> … </html>

<!-- or just one picker -->
<div class="vjdp-dark">
  <JalaliDatePicker v-model="date" />
</div>

Following the OS setting instead:

@media (prefers-color-scheme: dark) {
  .vjdp-calendar,
  .vjdp-popover {
    --vjdp-bg: #1d1615;
    --vjdp-fg: #f3e9e4;
    /* …override the other variables as needed */
  }
}

Class hooks

For anything the variables don't cover, every part has a stable, BEM-style class:

.vjdp-popover · .vjdp-trigger · .vjdp-input · .vjdp-popover__panel · .vjdp-calendar · .vjdp-month · .vjdp-month__title · .vjdp-nav · .vjdp-weekdays · .vjdp-day · .vjdp-day--selected · .vjdp-day--in-range · .vjdp-day--today · .vjdp-footer · .vjdp-btn · .vjdp-btn--primary


Jalali utilities

The conversion helpers the picker uses are exported too, so you don't need a separate date library for simple Jalali work:

import {
  toJalali,
  toGregorian,
  isJalaliLeapYear,
  jalaliMonthLength,
  jalaliFirstWeekday,
  shiftJalaliMonth,
  toFaDigits,
} from 'vue-jalali-datepicker'

toJalali(new Date(2025, 2, 21))              // { jy: 1404, jm: 1, jd: 1 }
toGregorian({ jy: 1404, jm: 1, jd: 1 })      // Date → Fri Mar 21 2025 00:00 (local)
isJalaliLeapYear(1403)                       // true
jalaliMonthLength(1404, 12)                  // 29
jalaliFirstWeekday(1404, 1)                  // 6  (0 = Saturday … 6 = Friday)
shiftJalaliMonth({ jy: 1404, jm: 12 }, 1)    // { jy: 1405, jm: 1 }
toFaDigits('1404/01/01')                     // '۱۴۰۴/۰۱/۰۱'
Function Signature Notes
toJalali (date: Date) => JalaliDate Reads the date in local time.
toGregorian (date: JalaliDate) => Date Returns local midnight.
isJalaliLeapYear (jy: number) => boolean 33-year cycle based arithmetic.
jalaliMonthLength (jy: number, jm: number) => number 31 / 30 / 29–30 (Esfand).
jalaliFirstWeekday (jy: number, jm: number) => number Saturday-first index of day 1.
jalaliKey (date: JalaliDate) => number Comparable integer, handy for sorting and equality.
shiftJalaliMonth (month: JalaliMonth, delta: number) => JalaliMonth Moves across year boundaries correctly.
toFaDigits (value: number | string) => string Converts 0-9 to ۰-۹.

The conversion is a closed-form implementation of the Borkowski algorithm, valid for Jalali years -61 to 3177. It's tested against Intl's Persian calendar for every single day between 1991 and 2051.


Working with dates

  • Values are plain Date objects at local midnight. The picker works with calendar days, so the time part of any Date you pass in is ignored.

  • Sending to an API? Serialize the way your backend expects. For a pure date, avoid toISOString() (it converts to UTC and can shift the day in positive-offset time zones such as Asia/Tehran). Prefer something like:

    const toYmd = (d: Date) =>
      `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
    
  • Range values are always ordered. start <= end once both are set, whichever order the user clicked in.

  • Popover drafts. While the popover is open the selection is a draft. modelValue only changes on confirm (or auto-apply). Closing without confirming discards the draft.


Accessibility

  • Every control is a native <button>, so you get keyboard focus and activation for free, with visible :focus-visible rings.
  • The navigation buttons and calendar toggle have localized aria-labels.
  • Today's date has aria-current="date", and selected days have aria-selected="true".
  • The trigger button reports its state through aria-expanded, and Esc closes the popover.
  • Disabled days use the native disabled attribute.
  • Transitions are switched off under prefers-reduced-motion: reduce.

Browser support

All evergreen browsers. The responsive two-month layout uses CSS container queries (Chrome/Edge 105+, Safari 16+, Firefox 110+). Older browsers still get a fully working single-month layout. The derived shades of the color prop use color-mix() (Chrome/Edge 111+, Safari 16.2+, Firefox 113+).


FAQ

The popover is clipped inside a scrolling container or modal.

The popover is positioned absolutely relative to the picker, so an ancestor with overflow: hidden can clip it. Give that container enough room, set overflow: visible, or use variant="inline" inside your own popover/dialog component.

Can I use it with Nuxt UI, Vuetify, PrimeVue, Tailwind…?

Yes. The component has no styling dependencies. Use the trigger slot to render your design system's input or button, and map your design tokens onto the --vjdp-* variables.

Does it support time selection?

Not yet. The picker works with calendar days. Pair it with your own time input and combine the values.

Can the week start on a different day?

The Persian week starts on Saturday, and the grid always follows that. Feel free to open an issue if you need a configurable first weekday.

Why not just use Intl for the Jalali conversion?

Intl can format dates in the Persian calendar but can't construct them, so converting back requires a search, and support varies between runtimes. The closed-form arithmetic used here is exact, fast, and behaves the same everywhere.


Contributing

Issues and pull requests are welcome! 🙌

git clone https://github.com/Ariaaghakhani/vue-jalali-datepicker.git
cd vue-jalali-datepicker
npm install

npm run dev        # interactive playground (playground/App.vue)
npm test           # unit tests (Vitest)
npm run typecheck  # vue-tsc
npm run build      # library build → dist/

Project layout

src/
├── components/
│   ├── JalaliDatePicker.vue    # public component: state, popover, v-model
│   ├── DatePickerCalendar.vue  # month grid(s) + footer
│   └── DatePickerMonth.vue     # a single month
├── jalali.ts                   # conversion & calendar utilities
├── locale.ts                   # faLocale / enLocale
├── color.ts                    # `color` prop → CSS variables
├── types.ts
├── style.css                   # all styles & CSS variables
└── index.ts                    # public exports + plugin

Before opening a PR, please make sure npm run typecheck, npm test and npm run build all pass. CI runs the same checks.

Releasing (maintainers): bump version in package.json (and add a CHANGELOG.md entry) in your pull request. When it's merged into main, the publish workflow type-checks, tests and builds the package, then publishes it to npm with provenance using the NPM_TOKEN repository secret. Merges that don't change the version skip publishing. The workflow can also be run by hand from the Actions tab.


License

MIT © Aria Aghakhani

Made with ❤️ for the Persian-speaking Vue community.

Related Packages

@jalali-js/vue

Vue bindings for jalali-js: useCalendar composable, headless Calendar grid, a de...

1,880 2
vue-persian-calendar-datepicker

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

3,651 0
khorshid

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

356
@khorshid-js/vue

Idiomatic Vue 3 bindings for Khorshid — composables + styled components for the...

220

Version History

Version Released Vue License
0.2.0 ^3.3.0 MIT
0.1.0 ^3.3.0 MIT
Pre-releases (1)
Version Released License
0.0.0-stage