Custom Date Picker (shadcn/ui, Hajri and Arabic–Indic Numerals)
Mohanad Alrwaihy
September 24, 2023
333
1
One of the most important components in the web is the Date Picker and figuring out how to build one or use an existing date picker is essential for the usability of your app.
7 min read
There are many ways to have a Date Picker on your site either by using a component library date picker like MUI Date Picker or using dependencies like react-datepicker
or react-day-picker
.
Date Picker Options
react-datepicker
- A classic simple reusable Datepicker component that is widely used around the web and can have a lot of customization options. (Preview, Github)react-datetime-picker
- Great looking datepicker out of the box and it's light, fast, and offers a lot of flexibility. Other date picker options are also provided by the creator Wojciech Maj. (Preview, NPM)react-dates
- An easily internationalizable, accessible, mobile-friendly datepicker library for the web. (Github, Preview).react-day-picker
- DayPicker is a date picker component for React. Renders a monthly calendar to select days. DayPicker is customizable, works great with input fields, and can be styled to match any design. (Preview, Github).
Custom Calendar
In this post I'm going to show how we can create a custom-designed datepicker with these features:
- Gregorian & Hajri Calendar.
- Arabic - Indic Numerals.
- Arabic Translation.
Using shadcn/ui calendar component which is built on top of react-day-picker
and uses date-fns
for manipulating JavaScritp dates.
Different Numbering Systems:
The spread of Hindu-Arabic numerals in the tradition of European practical mathematics
Preview
Tools
These are the tools I used to build the Custom Date Picker:
- React (Vite).
- Tailwind CSS.
- Shadcn/ui - Custom components.
Add shadcn/ui calendar
I'm not going the process of adding shadcn/ui to your project you can follow the installation page for more information or check my latest post Here showing how we can use shadcn/ui in our project.
Install Calendar component
To add Shadcn/ui Calendar 👇
POWERSHELL
# NPM
npx shadcn-ui@latest add calendar
# PNPM
pnpm dlx shadcn-ui@latest add calendar
Customize Calendar
We can customize the calendar by accessing the calendar component in /components/ui/calendar.tsx
.
Change base style
The DayPicker
component has classNames
for everything inside the calendar component that can be styled and customized as you like.
Below are my customized style changes 👇
/components/ui/calendar.tsx
TSX
// ...
<DayPicker
// ...
className={cn('w-full overflow-x-auto p-1.5 sm:p-3', className)}
classNames={{
months:
'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0 w-full',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm text-center font-medium mx-12',
nav: 'space-x-1 flex items-center pt-1',
nav_button: cn(
buttonVariants({ variant: 'secondary' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'space-y-2 w-full',
head_row: 'flex justify-between gap-1',
head_cell: 'text-muted-foreground w-9 font-bold text-[0.7rem]',
row: 'flex mt-2 justify-between',
cell: 'text-center text-sm w-9 h-9 p-0 relative rounded-lg focus-within:z-20 self-center my-auto flex flex-col items-center justify-center',
day: cn(
buttonVariants({ variant: 'ghost' }),
'w-full p-0 font-normal aria-selected:opacity-100 font-medium'
),
day_selected:
'bg-foreground text-background focus:bg-foreground focus:text-background',
day_today: 'bg-accent text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-foreground/70 aria-selected:text-background',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className='h-4 w-4 rtl:rotate-180' />,
IconRight: () => <ChevronRight className='h-4 w-4 rtl:rotate-180' />,
}}
{...props}
/>
Locale Options
To support and respect locale changes in the calendar component we need to pass the locale attribute to the calendar component and then we could show date numbers or month names based on the locale and change the date picker location from right-to-left 👇
TSX
...
import { arSA } from 'date-fns/locale'
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
...props
}: CalendarProps) {
const NU_LOCALE = locale === arSA
? 'ar-u-ca-nu-arab'
: 'en-u-ca-nu-latn'
const ISLAMIC_CALENDAR = locale === arSA
? 'ar-u-ca-islamic-umalqura-nu-arab'
: 'ar-u-ca-islamic-umalqura-nu-latn'
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={locale}
dir={locale === arSA ? 'rtl' : 'ltr'}
formatters={...}
disabled={...}
defaultMonth={...}
fromMonth={...}
fromYear={...}
toYear={...}
className={...}
classNames={...}
components={...}
{...props}
/>
)
}
The NU_LOCALE
variable is a very important variable that we are going to use in order to show the values in European Format or Hindu Arabic Format.
Also, we are going to use ISLAMIC_CALENDAR
variable to show the date in Umm al-Qura Calendar. 👇
TSX
const NU_LOCALE =
locale === arSA
? 'ar-u-ca-nu-arab' // Arabic-indic (٠, ١, ٢, ٣, ٤, ٥, ٦, ٧, ٨, ٩)
: 'en-u-ca-nu-latn' // Euoropean (0, 1, 2, 3, 4, 5, 6, 7 ,8, 9)
const ISLAMIC_CALENDAR =
locale === arSA
? 'ar-u-ca-islamic-umalqura-nu-arab'
: 'ar-u-ca-islamic-umalqura-nu-latn'
Warning
Notice that In the ISLAMIC_CALENDAR variable, I used ar-u-ca-islamic-umalqura-nu-latn
when the locale is not Arabic where I could have used en-u-ca-islamic-umalqura-nu-latn
which in fact will show the hajri months names in English instead or Arabic but I avoid this because I found a bug when opening the app in mobile where it will show Gregorian month with the correct Hajri year followed by BC at the end which do not make sense at all.
I want to format two things:
- Caption Label - Show Hajri month and Arabic-indic numerals when the locale is Arabic.
- Day - To show Hajri day below the Gregorian.
Luckily React DayPicker give us an easy option to format a lot things inside our custom date picker using the forammter function.
Format Caption
To format the header of the calendar where the arrows and the current month and year is visible.
TSX
...
import { DayPicker, type DateFormatter } from 'react-day-picker'
import { arSA } from 'date-fns/locale'
import { addMonths } from 'date-fns'
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
...props
}: CalendarProps) {
const NU_LOCALE = locale === arSA
? 'ar-u-ca-nu-arab'
: 'en-u-ca-nu-latn'
const ISLAMIC_CALENDAR = locale === arSA
? 'ar-u-ca-islamic-umalqura-nu-arab'
: 'ar-u-ca-islamic-umalqura-nu-latn'
const formatCaption: DateFormatter = (date) => {
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
}
const dateGregorian = date.toLocaleDateString(NU_LOCALE,options)
const dateHajri = date.toLocaleDateString(ISLAMIC_CALENDAR, options)
const nextMonth = addMonths(date, 1)
const dateHajriNext = nextMonth.toLocaleDateString(NU_LOCALE,options)
return (
<div>
<span>{dateGregorian}</span>
<span className='block text-[0.7rem] leading-4 text-orange-400'>
{dateHajri} - {dateHajriNext}
</span>
</div>
)
}
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={locale}
dir={locale === arSA ? 'rtl' : 'ltr'}
formatters={{ formatCaption, ... }}
disabled={...}
defaultMonth={...}
fromMonth={...}
fromYear={...}
toYear={...}
className={...}
classNames={...}
components={...}
{...props}
/>
)
}
Format Day
To format each cell showing a day in the month.
TSX
...
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
...props
}: CalendarProps) {
const NU_LOCALE = locale === arSA
? 'ar-u-ca-nu-arab'
: 'en-u-ca-nu-latn'
const ISLAMIC_CALENDAR = locale === arSA
? 'ar-u-ca-islamic-umalqura-nu-arab'
: 'ar-u-ca-islamic-umalqura-nu-latn'
const formatCaption: DateFormatter = (date) => {...}
const formatDay: DateFormatter = (day) => {
const options: Intl.DateTimeFormatOptions = { day: 'numeric' }
const dateGregorian = day.toLocaleDateString(NU_LOCALE, options)
const dateHajri = day.toLocaleDateString(ISLAMIC_CALENDAR, options)
return (
<div>
<span className='text-sm'>{dateGregorian}</span>
<span className='block text-[0.7rem] leading-3 text-orange-400 '>
{dateHajri}
</span>
</div>
)
}
return (
<DayPicker
showOutsideDays={showOutsideDays}
locale={locale}
dir={locale === arSA ? 'rtl' : 'ltr'}
formatters={{ formatCaption, formatDay }}
disabled={...}
defaultMonth={...}
fromMonth={...}
fromYear={...}
toYear={...}
className={...}
classNames={...}
components={...}
{...props}
/>
)
}
Add Start & End Dates
I found this very helpful for me to have props I can pass to set the date in a range that starts for example in 1950 to 2030 or have default values which will set the start date to the current date and the end date to the next 50 years.
Default Variables
There is a list of Default Variables we could set to be used when no options are provided by props.
month
- Will be used to show the current selected month.setMonth
- Change the current month.year
- This will be used to show the current selected year.setYear
- Change the current year.isHajri
- Replace the default numbering system to Hajri.setIsHajri
- Set the default numbering system.defaultMonth
- Set the default month from props or choose the current month on the month.
To control the calendar date manually we can use month
prop, in order to set the date of the calendar and onMonthChange
to change the date picker current display date.
TSX
...
import {useState} from 'react'
export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
start?: Date
end?: Date
hajri?: boolean
}
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
start,
end,
hajri,
...props
}: CalendarProps) {
const startDate = start ?? addDays(new Date(), -1)
const endDate = end ?? addYears(startDate, 50)
const [date, setDate] = useState(startDate)
const [month, setMonth] = useState(startDate.getMonth() + 1)
const [year, setYear] = useState(startDate.getFullYear())
const [isHajri, setIsHajri] = useState(hajri ?? false)
const defaultMonth =
props.defaultMonth ??
new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return (
<DayPicker
month={date}
onMonthChange={setDate}
{...}
{...props}
/>
)
}
Disable Dates
We could use the disabled
attributes to select the range where we want to disable dates.
Using Start & End states to dates 👇
TSX
...
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
start,
end,
hajri,
...props
}: CalendarProps) {
const startDate = start ?? addDays(new Date(), -1)
const endDate = end ?? addYears(startDate, 50)
const [date, setDate] = useState(startDate)
const [month, setMonth] = useState(startDate.getMonth() + 1)
const [year, setYear] = useState(startDate.getFullYear())
const [isHajri, setIsHajri] = useState(hajri ?? false)
const defaultMonth =
props.defaultMonth ??
new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return (
<DayPicker
month={date}
onMonthChange={setDate}
disabled={(date: Date) => startDate > date || endDate < date}
{...}
{...props}
/>
)
}
Default Month
When setting the disabled
date we will have to set the defaultMonth
and fromMonth
in order to stop the navigation of previous inaccessible dates 👇
TSX
...
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
start,
end,
hajri,
...props
}: CalendarProps) {
const startDate = start ?? addDays(new Date(), -1)
const endDate = end ?? addYears(startDate, 50)
const [date, setDate] = useState(startDate)
const [month, setMonth] = useState(startDate.getMonth() + 1)
const [year, setYear] = useState(startDate.getFullYear())
const [isHajri, setIsHajri] = useState(hajri ?? false)
const defaultMonth =
props.defaultMonth ??
new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return (
<DayPicker
month={date}
onMonthChange={setDate}
disabled={(date: Date) => startDate > date || endDate < date}
defaultMonth={defaultMonth}
fromMonth={defaultMonth}
{...}
{...props}
/>
)
}
Limit years selections
To limit years selection we can use the fromYear
and toYear
attributes to limit the selection between the start and end dates 👇
TSX
...
function Calendar({
className,
classNames,
locale,
showOutsideDays = true,
start,
end,
hajri,
...props
}: CalendarProps) {
const startDate = start ?? addDays(new Date(), -1)
const endDate = end ?? addYears(startDate, 50)
const [date, setDate] = useState(startDate)
const [month, setMonth] = useState(startDate.getMonth() + 1)
const [year, setYear] = useState(startDate.getFullYear())
const [isHajri, setIsHajri] = useState(hajri ?? false)
const defaultMonth =
props.defaultMonth ??
new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
return (
<DayPicker
month={date}
onMonthChange={setDate}
disabled={(date: Date) => startDate > date || endDate < date}
defaultMonth={defaultMonth}
fromMonth={defaultMonth}
fromYear={startDate.getFullYear()}
toYear={endDate.getFullYear()}
{...}
{...props}
/>
)
}
Change Month & Year
One of the important feature of a calendar we can add is the ability to change months or years seamlessly and to do that react-day-picker
offer a dropdown option by adding captionLayout="dropdown"
to display dropdown options to navigate months instead of using the next/previous arrows.
Adding the dropdown this way is good and works fine but we could make it better by creating out custom dropdown using the select
component provided by shadcn/ui.
All changes will be under the formatCaption
function.
Install select component
To add Shadcn/ui Select 👇
POWERSHELL
# NPM
npx shadcn-ui@latest add select
# PNPM
pnpm dlx shadcn-ui@latest add select
Helper Functions
TSX
// Change the selected month
function handleMonth(month: number) {
setMonth(month)
setDate(() => new Date(`${year.toString()}-${month}`))
}
// Change the selected year
function handleYear(year: number) {
setYear(year)
if (
startDate.getFullYear() === year &&
month < defaultMonth.getMonth() + 1
) {
setMonth(() => defaultMonth.getMonth() + 1)
setDate(
() => new Date(`${year.toString()}-${defaultMonth.getMonth() + 1}`)
)
} else setDate(() => new Date(`${year.toString()}-${month}`))
}
// To get calendar correct format.
function getCalendar(reverse?: boolean) {
if (reverse) return !isHajri ? ISLAMIC_CALENDAR : NU_LOCALE
return isHajri ? ISLAMIC_CALENDAR : NU_LOCALE
}
// Get a list of the available years in the calendar.
function getYears() {
return Array.from(
{ length: endDate.getFullYear() - startDate.getFullYear() + 1 },
(_, i) => startDate.getFullYear() + i
)
}
// Get a list of values to convert into the number of months.
function getMonths() {
return Array.from({ length: 12 }, (_, i) => i + 1)
}
Select Year & Month
To be able to select months we need to split the caption layout title that displays the current month and current year into two different variables:
dateMainYear
- Get the current year (Gregorian or Hajri).dateMainMonth
- Get the current Month (Gregorian or Hajri)
Also, we need to convert the secondary date visible option:
dateSecondary
- Show the opposite date calendardateNextSecondary
- Show the next month of the opposite calendar.dateNextMainMonth
- Will be used if the Hajri Calendar is chosen to display thedateMainMonth
anddateNextMinMonth
since there is the default structure of the calendar is based on the Gregorian calendar
TSX
const formatCaption: DateFormatter = (date: Date) => {
const dateMainYear = date.toLocaleDateString(getCalendar(), {
year: 'numeric',
})
const dateMainMonth = date.toLocaleDateString(getCalendar(), {
month: 'long',
})
const dateSecondaryOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
}
const dateSecondary = date.toLocaleDateString(
getCalendar(true),
dateSecondaryOptions
)
const nextMonth = addMonths(date, 1)
const dateNextMainMonth = nextMonth.toLocaleDateString(getCalendar(), {
month: 'long',
})
const dateNextSecondary = nextMonth.toLocaleDateString(
getCalendar(true),
dateSecondaryOptions
)
return (
<div className='flex flex-col gap-1'>
<div className='flex justify-between gap-5'>
<Select
dir={locale === arSA ? 'rtl' : 'ltr'}
onValueChange={(val: string) => handleMonth(Number(val))}
value={month.toString()}
>
<SelectTrigger className='h-full gap-2 border-none p-0 rtl:text-base'>
<p>
{!isHajri
? dateMainMonth
: `${dateMainMonth} - ${dateNextMainMonth}`}
</p>
</SelectTrigger>
<SelectContent>
{getMonths().map((currMonth) => (
<SelectItem
key={currMonth}
value={currMonth.toString()}
disabled={
isBefore(new Date(`${year}-${currMonth + 1}`), startDate) ||
isAfter(new Date(`${year}-${currMonth}`), endDate)
}
>
{new Date(`${year}-${currMonth}`).toLocaleDateString(
getCalendar(),
{ month: 'long' }
)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
dir={locale === arSA ? 'rtl' : 'ltr'}
onValueChange={(val: string) => handleYear(Number(val))}
value={year.toString()}
>
<SelectTrigger className='h-full gap-2 border-none p-0 rtl:text-base'>
<p>{dateMainYear}</p>
</SelectTrigger>
<SelectContent className='max-h-72 overflow-y-auto'>
{getYears().map((year) => (
<SelectItem key={year} value={year.toString()}>
{new Date(year.toString()).toLocaleDateString(getCalendar(), {
year: 'numeric',
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className='block text-[0.7rem] leading-4 text-orange-400'>
{isHajri ? dateSecondary : `${dateSecondary} - ${dateNextSecondary}`}
</span>
</div>
)
}
How to use
Using the Calendar
component is very easy and we can override the default settings as we want to match our use case.
Calendar Mods
DayPicker supports 3 built-in selection modes to display days as selected. Enable a selection mode by setting the mode
prop.
- Single mode
mode="single"
: only a single day can be selected - Multiple mode
mode="multiple"
: allow selection of multiple days - Range mode
mode="range"
: allow the selection of range of days
Single mode
TSX
import {useState} from 'react'
export default function App(){
const [date, setDate] = useState<Date>()
return (
<Calendar
locale={enUS} // Enable (LTR) && Euoropean Numbers
mode='single' // Enable Single mode
selected={date}
onSelect={setDate}
/>
)
}
Multiple mode
TSX
import {useState} from 'react'
export default function App(){
const [multiple, setMultiple] = useState<Date[] | undefined>([]);
return (
<Calendar
locale={enUS} // Enable (LTR) && Euoropean Numbers
mode='multiple' // Enable Multiple mode
selected={multiple}
onSelect={setMultiple}
/>
)
}
Range mode
TSX
import {useState} from 'react'
import { type DateRange } from 'react-day-picker'
export default function App(){
const [range, setRange] = useState<DateRange | undefined>(defaultSelected)
return (
<Calendar
locale={enUS} // Enable (LTR) && Euoropean Numbers
mode='range' // Enable Single mode
selected={range}
onSelect={setRange}
/>
)
}
Other options
There are a lot of options provided by React DayPicker to change how the Calendar
components work you can scroll through the documentation to learn more about all the different options.