Skip to contentVibraUI
Inputs & filters

Calendar

A month grid on react-day-picker for picking a day or a range.

Vibra passes react-day-picker's focus ref through to the day button, so the arrow keys move real focus from day to day — shadcn's copy creates the ref and never attaches it — and marks that day once, with the kit's 2px outline. Its parts are the same components from one render to the next, so a press never rebuilds the grid under the pointer. A range's rounded ends and the band between them sit on the inline axis, so a right-to-left range mirrors. A day's data-day and the month dropdown are formatted in the calendar's locale (en-US unless you pass one) rather than the runtime's, so a server render hydrates in any browser. Pass timeZone="UTC" with midnight-UTC dates so a day is the same day for every reader, and a fixed today wherever a demo or a test must not move with the clock. A disabled day stays in the grid and the arrow keys pass it by; put its reason in its name through labels.labelDayButton. In multiple mode max starts over from the pressed day once the limit is reached — to refuse the extra day instead, check the length in onSelect. The file also exports PickerCalendarProps and pickerDisabled, the calendar props DatePicker and DateRangePicker hand on to the grid in their popovers (everything but the mode, the selection, the months and the focus) and the rule that adds a caller's disabled days to min and max. Weekday and week-number labels use the 13px label register, and selected days fill in the accent through --primary.

Install

npx shadcn@latest add @vibra/calendar

Needs the @vibra registry in your components.json — set it up once.

Examples

A stay, counted in nights

mode="range" with resetOnSelect: the night you arrive, then the day you leave, and a status line that counts the nights.

Two months for a longer range

A campaign's run across the month end: two months side by side where there is room, one on a phone.

Several days, up to a limit

mode="multiple": days off named and counted against five, and a sixth refused with the reason.

Whole weeks, with their numbers

ISO week numbers down the side; a press on any day plans its week, Monday to Sunday.

Jump by month and year

captionLayout="dropdown" for a date years back, and a button that brings the view to this month.

Unavailable days, with the reason

Weekends, a holiday and the notice period dimmed; each day is named with its reason and the grid points at the rules.

A rate on each night

A custom day button with the night's rate, which is part of the day's name; the lowest are said in words too.

With a time

A day and a time from a Select beside it, read back as one line in UTC.

A day, then a free slot

The slots follow the day; a booked one stays in the list and says so, and the chosen one is pressed on the brand tint.

Range presets

Report ranges a press away, counted back from the reference date; the preset that matches is pressed.

Right to left

The booking range in Arabic with dir="rtl": the week runs from the right, and the range's ends and chevrons follow it.

Props

PropTypeDefaultDescription
mode"single" | "multiple" | "range"—What a press selects: one day, a set of days, or a range from a first day to a last.
selected / onSelectDate | Date[] | DateRange—The selection, in the shape the mode gives it. onSelect also hands you the day that was pressed.
timeZonestring—The zone the grid's days are counted in. "UTC" with midnight-UTC dates keeps a day the same for every reader.
todayDatethe clockThe day marked as today, and the month shown when neither month nor defaultMonth is set.
disabledMatcher | Matcher[]—Days that can't be picked: dimmed, native-disabled and passed by the arrow keys.
labelsPartial<Labels>—The names the grid reads out. Extend labelDayButton with a disabled day's reason, a rate or a count.
numberOfMonthsnumber1Months side by side; the arrows still page a month at a time.
month / onMonthChangeDate—The month in view, controlled — for a preset or a button that moves the view.
startMonth / endMonthDate—The first and last months the arrows and the dropdowns reach.
resetOnSelectbooleanfalseRange mode: a press after a complete range starts a new one rather than moving an end.
excludeDisabledbooleanfalseRange mode: a range that would take in a disabled day starts over from the pressed day.
showWeekNumber / ISOWeekbooleanfalseA column of week numbers; ISOWeek counts them the ISO way, Monday to Sunday.
componentsPartial<CustomComponents>—Swap a part: a DayButton that wraps CalendarDayButton adds a line under the date and keeps the kit's look.
classNamesPartial<ClassNames>—Classes per part. A part you name takes your classes in place of the kit's — a tighter week row for a taller day.
formattersPartial<Formatters>—How captions and weekday names are written; formatWeekdayName can narrow them to fit a column.
locale / dirLocale / "ltr" | "rtl"—Month and weekday names in a language, and the direction the week runs; the arrows and the range's ends follow dir.
buttonVariantButtonProps["variant"]"ghost"The Button variant of the previous and next month arrows.
captionLayout"label" | "dropdown" | "dropdown-months" | "dropdown-years""label"A plain month label, or dropdowns for jumping by month and year.
showOutsideDaysbooleantrueShows the neighbouring months' days in the first and last weeks.

Dependencies

Source

components/ui/calendar.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import {
  DayPicker,
  getDefaultClassNames,
  useDayPicker,
  type CustomComponents,
  type DayButton,
  type Locale,
  type Matcher,
  type PropsBase,
} from "react-day-picker"

import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"

/**
 * react-day-picker writes its own labels in en-US unless it is given a locale.
 * The two strings this file formats itself — a day's data-day and the month
 * dropdown — follow that default rather than the runtime's, so a calendar
 * rendered on a server in one locale hydrates in a browser set to another.
 */
const DEFAULT_LOCALE_CODE = "en-US"

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(
        "group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: (date) =>
          date.toLocaleString(locale?.code ?? DEFAULT_LOCALE_CODE, { month: "short" }),
        ...formatters,
      }}
      classNames={{
        root: cn("w-fit", defaultClassNames.root),
        months: cn(
          "relative flex flex-col gap-4 md:flex-row",
          defaultClassNames.months
        ),
        month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
        nav: cn(
          "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
          defaultClassNames.nav
        ),
        button_previous: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
          defaultClassNames.button_previous
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
          defaultClassNames.button_next
        ),
        month_caption: cn(
          "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
          defaultClassNames.month_caption
        ),
        dropdowns: cn(
          "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
          defaultClassNames.dropdowns
        ),
        dropdown_root: cn(
          "relative rounded-(--cell-radius)",
          defaultClassNames.dropdown_root
        ),
        dropdown: cn(
          "absolute inset-0 bg-popover opacity-0",
          defaultClassNames.dropdown
        ),
        caption_label: cn(
          "font-medium select-none",
          captionLayout === "label"
            ? "text-sm"
            : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
          defaultClassNames.caption_label
        ),
        month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
        weekdays: cn("flex", defaultClassNames.weekdays),
        weekday: cn(
          "flex-1 rounded-(--cell-radius) text-label font-normal text-muted-foreground select-none",
          defaultClassNames.weekday
        ),
        week: cn("mt-2 flex w-full", defaultClassNames.week),
        week_number_header: cn(
          "w-(--cell-size) select-none",
          defaultClassNames.week_number_header
        ),
        week_number: cn(
          "text-label text-muted-foreground select-none",
          defaultClassNames.week_number
        ),
        day: cn(
          "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)",
          props.showWeekNumber
            ? "[&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)"
            : "[&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)",
          defaultClassNames.day
        ),
        range_start: cn(
          "relative isolate z-0 rounded-s-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:end-0 after:w-4 after:bg-muted",
          defaultClassNames.range_start
        ),
        range_middle: cn("rounded-none", defaultClassNames.range_middle),
        range_end: cn(
          "relative isolate z-0 rounded-e-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:start-0 after:w-4 after:bg-muted",
          defaultClassNames.range_end
        ),
        today: cn(
          "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
          defaultClassNames.today
        ),
        outside: cn(
          "text-muted-foreground aria-selected:text-muted-foreground",
          defaultClassNames.outside
        ),
        disabled: cn(
          "text-muted-foreground opacity-50",
          defaultClassNames.disabled
        ),
        hidden: cn("invisible", defaultClassNames.hidden),
        ...classNames,
      }}
      // The parts are module-level functions, the same from one render to the
      // next: written inline, each render handed react-day-picker new
      // component types and React rebuilt the whole grid under the pointer.
      components={{
        Root: CalendarRoot,
        Chevron: CalendarChevron,
        DayButton: CalendarDayButton,
        WeekNumber: CalendarWeekNumber,
        ...components,
      }}
      {...props}
    />
  )
}

function CalendarRoot({
  className,
  rootRef,
  ...props
}: React.ComponentProps<CustomComponents["Root"]>) {
  return (
    <div
      data-slot="calendar"
      ref={rootRef}
      className={cn(className)}
      {...props}
    />
  )
}

function CalendarChevron({
  className,
  orientation,
  ...props
}: React.ComponentProps<CustomComponents["Chevron"]>) {
  if (orientation === "left") {
    return <ChevronLeftIcon className={cn("size-4", className)} {...props} />
  }

  if (orientation === "right") {
    return <ChevronRightIcon className={cn("size-4", className)} {...props} />
  }

  return <ChevronDownIcon className={cn("size-4", className)} {...props} />
}

// A row header, as react-day-picker's own is: it passes `scope="row"`, which
// only a th may carry, and the week object itself, which is data rather than
// an attribute — spread onto a td it wrote week="[object Object]".
function CalendarWeekNumber({ children, ...props }: React.ComponentProps<CustomComponents["WeekNumber"]>) {
  const cell: Partial<typeof props> = { ...props }
  delete cell.week
  return (
    <th {...cell}>
      <div className="flex size-(--cell-size) items-center justify-center text-center font-normal">
        {children}
      </div>
    </th>
  )
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()
  // The calendar's own locale unless one is passed, so a custom DayButton
  // that wraps this one keeps the day's data-day in the calendar's language.
  const { dayPickerProps } = useDayPicker()
  const localeCode = (locale ?? dayPickerProps.locale)?.code ?? DEFAULT_LOCALE_CODE

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      // COUPLED TO upstream shadcn calendar: the focus ref must reach the Button for react-day-picker's keyboard day navigation; re-check after a CLI refresh.
      ref={ref}
      variant="ghost"
      size="icon"
      data-day={day.date.toLocaleDateString(localeCode)}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        "relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-e-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-s-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
        defaultClassNames.day,
        className
      )}
      {...props}
    />
  )
}

/**
 * What a picker hands on to the calendar in its popover: `today`, `labels`,
 * `modifiers`, `footer`, `weekStartsOn` and the rest — everything but what the
 * picker owns (the mode, the selection, the months it opens on and can reach,
 * the focus). A `disabled` given here is added to the days min and max already
 * rule out. The picker's days are the runtime's own calendar days, so the
 * calendar takes no time zone of its own.
 */
export type PickerCalendarProps = Omit<
  PropsBase,
  | "mode"
  | "required"
  | "month"
  | "defaultMonth"
  | "onMonthChange"
  | "startMonth"
  | "endMonth"
  | "autoFocus"
  | "timeZone"
  | "noonSafe"
>

/** The days a picker rules out: min and max, then whatever the caller's calendar props add. */
export function pickerDisabled(min: Date | undefined, max: Date | undefined, extra: Matcher | Matcher[] | undefined): Matcher[] {
  return [
    ...(min ? [{ before: min }] : []),
    ...(max ? [{ after: max }] : []),
    ...(extra === undefined ? [] : Array.isArray(extra) ? extra : [extra]),
  ]
}

export { Calendar, CalendarDayButton }