Calendar React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React calendar to pick one day, several days or a range, with disabled days, event dots, several months and a quick month / year jump.
Click a day to pick it; in multiple mode each click adds or removes a day, and in range mode the first click sets the start, the band follows the pointer as a preview, and the second click sets the end (a day before the start swaps them). The arrows move a month (or a page with pagedNavigation) and the grid slides the way you went. Click the caption for a months grid, click again for years, pick one to zoom back in. Keyboard: Tab lands on one day (the selected, else today); arrows move a day or a week, Home / End jump to the week's ends, PageUp / PageDown move a month and Shift+PageUp / PageDown a year, Enter or Space picks; in the months and years grids arrows move, Enter picks and Escape returns to the days. On a phone the cells grow to 44px and a swipe left or right changes the month; vertical scrolling still works.
Calendar is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add calendar, together with Button, Date utils, useControllableState, useLocale, useMediaQuery and 3 more and the npm packages date-fns, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add calendarCalendar is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import Calendar from @/components/ui/calendar.
import { useState } from "react"
import { Calendar, type DateRange } from "@/components/ui/calendar"
const booked = [new Date(2026, 9, 9), new Date(2026, 9, 10), new Date(2026, 9, 11)]
export function StayPicker() {
const [stay, setStay] = useState<DateRange>({ from: null, to: null })
return (
<Calendar
mode="range"
value={stay}
onValueChange={setStay}
min={new Date()}
disabled={booked}
minDays={3}
strikeDisabled
events={[{ date: new Date(2026, 9, 14), tone: "warning", label: "Request pending" }]}
labels={{ today: "Back to today", selected: "picked" }}
/>
)
}Props
modedefault"single""single" | "multiple" | "range"What a click picks: one day, a set of days (each click adds or removes one), or a range (start, then end). The value type follows the mode: Date | null, Date[] or { from, to }.
valueDate | null | Date[] | DateRangeThe selection, controlled. Typed by mode: Date | null for single, Date[] for multiple, { from: Date | null; to: Date | null } for range. A value outside min / max shows as given; only clicks are limited.
defaultValueDate | null | Date[] | DateRangeThe starting selection while uncontrolled: null, [] or { from: null, to: null } by mode.
onValueChange(value: Date | null | Date[] | DateRange) => voidCalled on every pick with the next value (typed by mode). In single mode a new day keeps the time of the previous value, so a date-time picker never loses its time.
monthDateThe first visible month, controlled. Pair it with onMonthChange.
defaultMonthDateThe first visible month on mount. Defaults to the value's month, else today's, and is clamped to min / max.
onMonthChange(month: Date) => voidCalled with the first day of the new first visible month after an arrow, a swipe, a keyboard move or a jump.
numberOfMonthsdefault1numberMonths side by side from 640px wide; below that they stack, each with its own caption. In a narrow container on a wide screen, stack them with classNames={{ months: "sm:flex-col" }} (or a container query).
pagedNavigationdefaultfalsebooleanThe arrows and swipes move by numberOfMonths instead of one month.
minDateEarlier days are disabled and the previous arrow stops at this month.
maxDateLater days are disabled and the next arrow stops at this month. min after max leaves nothing enabled (a dev warning says so).
disabledDateMatcher or DateMatcher[] (Date, Date[], { from, to }, { before }, { after }, { dayOfWeek } or (date) => boolean)Days that can not be picked: a Date, a list, { from, to }, { before }, { after }, { dayOfWeek: [0, 6] } for weekends, or a (date) => boolean test. Disabled days stay focusable (aria-disabled) so the keyboard can pass over them.
localedefault""stringMonth and weekday names, digits and the first day of the week, all from Intl. Empty uses the nearest LocaleProvider, else the page's language. en-US starts on Sunday, de-DE on Monday.
weekStartsOndefault-10 | 1 | 2 | 3 | 4 | 5 | 6 | -1The first day of the week: 0 is Sunday, 1 Monday ... 6 Saturday; -1 follows the locale (Sunday in en-US, Monday in de-DE).
showOutsideDaysdefaulttruebooleanShows the neighbour months' days that fill the first and last week, dimmed. They can be picked and move the view there.
fixedWeeksdefaultfalsebooleanAlways six weeks, so the grid never changes height between months (pickers turn it on). Without it the height change animates.
showWeekNumbersdefaultfalsebooleanA column of week numbers: ISO weeks when the week starts on Monday, the locale's numbering otherwise. On touch screens md days take 40px instead of 44px to make room, so it still fits a 360px phone.
events{ date: Date; tone?: Tone; color?: string; label?: string }[]Dots under a day (up to 3): bookings, deadlines, holidays. tone or any color; labels are read out with the day. On a selected day the dots turn the selection's text color.
monthYearPickerdefaulttruebooleanThe caption becomes a button: first a months grid, then a years grid (20 at a time), for fast jumps to a birthday or next year. Off, the caption is plain text.
minDaysdefault0numberRange mode: the shortest range in days, both ends counted (a 2 night minimum stay is 3). While picking the end, closer days are unavailable; clicking the start again then clears it. 0 = no minimum.
maxDaysdefault0numberRange mode: the longest range in days, both ends counted. Farther days are unavailable while picking the end. 0 = no limit.
excludeDisableddefaulttruebooleanRange mode: a range can not span a disabled day. While picking the end, every day past the first disabled one on each side is unavailable (booked nights in a stay).
maxSelecteddefault0numberMultiple mode: how many days can be picked. At the limit the other days are unavailable until one is removed. 0 = no limit.
sizedefault"md""sm" | "md" | "lg"Day cells of 32, 36 or 44px. On touch screens md grows to 44px, so seven columns still fit a 360px phone.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The selection color: the day fill, the range band and the months / years highlight.
colordefault""stringAny CSS color for the selection; replaces the tone. Text on the fill turns black or white by lightness and the band is a tint of it.
radiusdefault"full""md" | "full"The day shape: circles (the default, like iOS) or rounded squares. The range band rounds to match.
strikeDisableddefaultfalsebooleanStrikes through disabled days, for booked nights and sold-out dates.
todayButtondefaultfalsebooleanA Today button under the grid that jumps back to the current month (and closes the months / years view).
labelsPartial<{ previousMonth; nextMonth; previousYear; nextYear; previousYears; nextYears; chooseMonth; chooseYear; today; weekNumber; selected; event: string }>Every visible and spoken string, merged over the English defaults ("Previous month", "Today", "selected"...). Pass translated ones in another language.
renderDay(date: Date, state: { today; selected; rangeStart; rangeMiddle; rangeEnd; outside; disabled; hoverRange; events }) => ReactNodeThe content inside a day button, in place of the number: a price under each day, a badge. Use size lg for two lines.
footerReactNodeContent under the grid: a range summary, a time field, actions.
autoFocusdefaultfalsebooleanFocuses the selected day (else today) on mount. Pickers pass it when they open from the keyboard.
namedefault""stringRenders hidden inputs for native forms, as local ISO dates ("2026-09-23"): one for single, one per day for multiple, name.from and name.to for a range. A form reset returns to defaultValue. The demo prints what the form would submit.
classNamesPartial<Record<"header" | "caption" | "nav" | "months" | "grid" | "weekday" | "day" | "today" | "selected" | "range" | "outside" | "disabled" | "events" | "view" | "footer", string>>Extra classes for the inner parts. range goes on the cells that carry the band, the day keys on the day buttons.
classNamestringExtra classes for the root. Override the tone vars here, e.g. [--tone-soft:...] for a custom band.
Examples
Booking a stay
Range mode with booked nights struck through, a 2 night minimum (minDays 3) and the total in the footer.
Several days
multiple with maxSelected 3 and weekends disabled; at the limit the other days wait until one is removed.
Two months, paged
numberOfMonths 2 with pagedNavigation: the arrows move both months. They stack below 640px; here a container query on classNames.months also stacks them in a narrow panel.
Prices under each day
renderDay puts a nightly price under the number; size lg leaves room for two lines.
Locales
Names, digits and the first day of the week come from Intl: Sunday in en-US, Monday in ro-RO and de-DE.
Birthday
max is today; click the caption to jump by month, then by year. Today brings you back.
Sizes, shapes, week numbers
32px cells with rounded squares and ISO week numbers, next to 44px circles.
Custom color and events
Any CSS color for the selection, and up to three dots a day from tones or colors.
Dependencies
Files
- components/ui/calendar.tsx
FAQ
Is the React Calendar component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Calendar?
Run npx wingo-ui@latest add calendar. It also adds the Wingo UI items it builds on (Button, Date utils, useControllableState, useLocale, useMediaQuery and 3 more) and the npm packages date-fns, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/calendar.
How does Calendar behave on phones?
On a phone the cells grow to 44px and a swipe left or right changes the month; vertical scrolling still works.
See also
Used in
More React input components
Articles using this component
- React Appointment Booking That Gets Time Zones Right
Build it: screens and apps9 min read
- Shadcn Date Time Picker with Time Zones and Presets
Component guides9 min read
- React Calendar Component with Events: Month, Week, Day
Component guides10 min read
- React Date Range Picker With Presets and a Phone Sheet
Component guides9 min read