Range Calendar
range-calendar ui Inline from→to calendar grid. Click two dates; the range fills between. For a form field with a trigger, use Date Picker type="range" instead. Same min/max and disabled-date support as Calendar.
Also available for Vue ->When to use which
Calendar is the grid. Date Picker is the product control (field + popover). Use the primitive when you own layout; use the picker when you need a form field.
| Component | Role | Use when |
|---|---|---|
| Calendar Inline month UI, custom popovers, dashboards | Primitive grid | |
| Range Calendar Inline from→to selection without a field | Primitive range grid | |
| Date Picker Inputs, filters, booking — trigger + popover + calendar + optional time | Form product |
- Form / filter / booking field → Date Picker (open)
- Always-visible month → Calendar (open)
- From → to in a field → Date Picker
type="range"(not bare Range Calendar unless the grid is inline)
Locale & timezone
- Vue uses
@internationalized/date+getLocalTimeZone()— calendar days are timezone-safe civil dates, not UTC midnight hacks. - React Calendar uses
react-day-pickerwith nativeDate— still local-browser wall time; convert at the API boundary if your backend is UTC. -
Pass
localefor weekday/month labels. Value strings from Date Picker are ISOYYYY-MM-DD(or withTwhenshow-time). -
For multi-region apps: store UTC instants server-side; map to/from local civil dates in the UI layer using the libraries above — do not stringify
toISOString().slice(0,10)for display dates (shifts the day near midnight).
Installation
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/range-calendar.json $ npx shadcn@latest add https://uipkge.dev/r/react/range-calendar.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/range-calendar.json $ bunx shadcn@latest add https://uipkge.dev/r/react/range-calendar.json Named registry:
npx shadcn@latest add @uipkge-react/range-calendar Installs to: components/ui/range-calendar/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
selected | DateRange | — | optional |
onSelect | (range: DateRange | undefined) => void | — | optional |
npm dependencies
Includes
Used by
Files installed (2)
-
components/ui/range-calendar/RangeCalendar.tsx 6.1 kB
'use client' import * as React from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import { DayPicker, type DateRange } from 'react-day-picker' import { cn } from '@/lib/utils' import { buttonVariants } from '@/components/ui/button' // `selected`/`onSelect` are re-declared explicitly: a bare // Omit<ComponentProps<DayPicker>, 'mode'> distributes over DayPicker's prop // union and drops `selected` (not common to every mode), so consumers couldn't // pass a range. Forcing mode="range" makes the range shape exact. export interface RangeCalendarProps extends Omit< React.ComponentProps<typeof DayPicker>, 'mode' | 'selected' | 'onSelect' > { selected?: DateRange onSelect?: (range: DateRange | undefined) => void } /** * Date-range calendar — click two dates and the range fills in between. The * React mirror of the reka-ui-based Vue RangeCalendar: range start/end cells * get the primary fill, the middle gets the accent fill, with the same cell * sizes, today / outside / disabled states, and outline nav chevrons mapped * 1:1 from the Vue registry's Tailwind class strings. Forces `mode="range"`. */ function RangeCalendar({ className, classNames, showOutsideDays = true, selected, onSelect, ...props }: RangeCalendarProps) { return ( <DayPicker mode="range" selected={selected} onSelect={onSelect} data-uipkge="" data-slot="range-calendar" showOutsideDays={showOutsideDays} className={cn('p-3', className)} classNames={{ // react-day-picker v10 renders <Nav> as the FIRST child of .rdp-months // (a sibling of the month, NOT inside month_caption). The nav is // `absolute inset-x-0 top-0`, so .rdp-months MUST be `relative` or the // chevrons escape to the nearest positioned ancestor and float away from // the calendar. months relative + an h-9 caption overlays the nav on the // caption row, prev/next pinned to the inset-x edges — matching Vue. // No mt-4: rdp's .rdp-months wraps the nav + caption, so a top margin // would push the header down 16px vs Vue (where mt-4 sits on the grid // wrapper). Caption→grid gap is the Month's gap-4. months: 'relative flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0', // flex-1: the extra Month wrapper (Months > Month > grid) collapses to the // grid's intrinsic width in the sm:flex-row track, leaving the grid narrow // and the centered caption drifting left. flex-1 makes Month fill its track // → full-width grid + centered label, matching Vue. month: 'flex flex-1 flex-col gap-4', // Label-height (no h-9) so the nav's top-0 aligns with the caption label, // matching Vue (size-9 buttons overflow below the heading-sized header). month_caption: 'flex items-center justify-center', caption_label: 'text-sm font-medium', // Vue's RangeCalendar nav is NOT the single Calendar's nav: it uses the // smaller size-7 / opacity-50 buttons pinned 4px in (left-1 / right-1), // not size-9 / opacity-70 at the inset-x-0 edge. inset-x-1 reproduces the // left-1 / right-1 offset; the buttons match RangeCalendarPrev/NextButton. nav: 'absolute inset-x-1 top-0 flex items-center justify-between', button_previous: cn( buttonVariants({ variant: 'outline' }), 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', ), button_next: cn( buttonVariants({ variant: 'outline' }), 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100', ), month_grid: 'w-full border-collapse space-y-1', weekdays: 'flex', weekday: 'text-muted-foreground flex-1 rounded-md text-xs font-normal', week: 'mt-2 flex w-full', day: cn( 'relative flex-1 p-0 text-center text-sm focus-within:relative focus-within:z-20', '[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md', '[&:has([aria-selected].day-outside)]:bg-accent/50 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md', ), day_button: cn( buttonVariants({ variant: 'ghost' }), 'h-8 w-8 cursor-pointer p-0 font-normal aria-selected:opacity-100', ), today: '[&:not([aria-selected])]:bg-accent [&:not([aria-selected])]:text-accent-foreground', outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground', disabled: 'text-muted-foreground opacity-50', // Range start / end → primary fill (mirrors Vue's data-[selection-start] / -end) range_start: 'day-range-start rounded-l-md [&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:hover]:text-primary-foreground [&>button:focus]:bg-primary [&>button:focus]:text-primary-foreground', range_end: 'day-range-end rounded-r-md [&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:hover]:text-primary-foreground [&>button:focus]:bg-primary [&>button:focus]:text-primary-foreground', // Range middle → accent fill range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground [&>button]:hover:bg-accent', hidden: 'invisible', ...classNames, }} formatters={{ // Match Vue's narrow (single-letter) weekday labels; react-day-picker // defaults to short 'cccccc' (two-letter "Mo"). Locale-aware narrow. formatWeekdayName: (weekday, options) => weekday.toLocaleDateString(options?.locale?.code, { weekday: 'narrow' }), ...props.formatters, }} components={{ Chevron: ({ orientation, className: chevronClassName, ...chevronProps }) => orientation === 'left' ? ( <ChevronLeft className={cn('size-4', chevronClassName)} aria-hidden="true" {...chevronProps} /> ) : ( <ChevronRight className={cn('size-4', chevronClassName)} aria-hidden="true" {...chevronProps} /> ), }} {...props} /> ) } RangeCalendar.displayName = 'RangeCalendar' export { RangeCalendar } -
components/ui/range-calendar/index.ts 0.1 kB
export { RangeCalendar, type RangeCalendarProps } from './RangeCalendar'
Raw manifest: https://uipkge.dev/r/react/range-calendar.json