A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
import { DatePicker } from "@/components/ui/date-picker"
export function DatePickerDemo() {
return <DatePicker />
}pnpm dlx shadcn@latest add https://hextaui.com/r/date-picker.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn react-day-picker - 3
Copy and paste the following code into your project.
components/ui/date-picker.tsx "use client" import * as React from "react" import { IconCalendar } from "@tabler/icons-react" import { cn } from "cn" import type { DateRange } from "react-day-picker" import { Button } from "@/components/ui/button" import { Calendar, type CalendarProps } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Sheet, SheetBody, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet" const phoneQuery = "(max-width: 639px)" function subscribePhone(onChange: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const media = window.matchMedia(phoneQuery) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) } function getPhone() { return typeof window.matchMedia === "function" ? window.matchMedia(phoneQuery).matches : false } function useIsPhone() { return React.useSyncExternalStore(subscribePhone, getPhone, () => false) } function toIsoDate(date: Date) { const month = String(date.getMonth() + 1).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0") return `${date.getFullYear()}-${month}-${day}` } function normalizeSpaces(text: string) { return text.replace(/[\u2009\u202f\u00a0]/g, " ") } function isSameDay(a: Date, b: Date) { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ) } type CalendarPassthrough = Omit< CalendarProps, "mode" | "selected" | "onSelect" | "required" | "numberOfMonths" | "autoFocus" > type TriggerProps = Omit< React.ComponentProps<typeof Button>, "value" | "defaultValue" | "onChange" | "children" | "render" > type DatePickerSharedProps = TriggerProps & { open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void placeholder?: string title?: string locale?: string clearable?: boolean clearLabel?: string name?: string calendarProps?: CalendarPassthrough } type DatePickerShellProps = Omit< DatePickerSharedProps, "locale" | "calendarProps" > & { label: string | undefined hasValue: boolean hiddenValue: string | undefined onClear: () => void children: (close: () => void, phone: boolean) => React.ReactNode } function DatePickerShell({ open: openProp, defaultOpen = false, onOpenChange, placeholder, title, label, hasValue, hiddenValue, clearable = false, clearLabel = "Clear", name, onClear, children, className, variant = "outline", disabled, ...props }: DatePickerShellProps) { const phone = useIsPhone() const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const open = openProp ?? uncontrolledOpen const setOpen = (next: boolean) => { if (openProp === undefined) { setUncontrolledOpen(next) } onOpenChange?.(next) } const close = () => setOpen(false) const trigger = ( <Button variant={variant} disabled={disabled} data-slot="date-picker-trigger" data-empty={hasValue ? undefined : ""} className={cn( "w-60 max-w-full justify-start gap-2 px-3 text-start font-normal tabular-nums data-empty:text-muted-foreground", className )} {...props} /> ) const triggerContent = ( <> <IconCalendar aria-hidden className="text-muted-foreground" /> <span className="min-w-0 truncate">{label ?? placeholder}</span> </> ) const clearButton = clearable && hasValue ? ( <Button type="button" variant="ghost" size="sm" data-slot="date-picker-clear" onClick={() => { onClear() close() }} > {clearLabel} </Button> ) : null const hidden = name !== undefined ? ( <input type="hidden" name={name} value={hiddenValue ?? ""} /> ) : null if (phone) { return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger render={trigger}>{triggerContent}</SheetTrigger> {hidden} <SheetContent side="bottom"> <SheetHeader> <SheetTitle>{title}</SheetTitle> </SheetHeader> <SheetBody> <div data-slot="date-picker-content" className="flex justify-center" > {children(close, true)} </div> </SheetBody> {clearButton ? <SheetFooter>{clearButton}</SheetFooter> : null} </SheetContent> </Sheet> ) } return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger render={trigger}>{triggerContent}</PopoverTrigger> {hidden} <PopoverContent align="start" aria-label={title} className="w-auto gap-0 p-0" > <div data-slot="date-picker-content">{children(close, false)}</div> {clearButton ? ( <div className="flex justify-end border-t px-2 py-1.5"> {clearButton} </div> ) : null} </PopoverContent> </Popover> ) } type DatePickerProps = DatePickerSharedProps & { value?: Date | null defaultValue?: Date | null onValueChange?: (value: Date | null) => void } function DatePicker({ value: valueProp, defaultValue = null, onValueChange, placeholder = "Pick a date", title = "Select a date", locale = "en-US", calendarProps, ...props }: DatePickerProps) { const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue) const value = valueProp !== undefined ? valueProp : uncontrolledValue const formatter = React.useMemo( () => new Intl.DateTimeFormat(locale, { dateStyle: "medium" }), [locale] ) const setValue = (next: Date | null) => { if (valueProp === undefined) { setUncontrolledValue(next) } onValueChange?.(next) } return ( <DatePickerShell {...props} placeholder={placeholder} title={title} label={value ? normalizeSpaces(formatter.format(value)) : undefined} hasValue={Boolean(value)} hiddenValue={value ? toIsoDate(value) : undefined} onClear={() => setValue(null)} > {(close) => ( <Calendar {...calendarProps} mode="single" required autoFocus selected={value ?? undefined} defaultMonth={value ?? calendarProps?.defaultMonth} onSelect={(date) => { setValue(date) close() }} /> )} </DatePickerShell> ) } type DateRangePickerProps = DatePickerSharedProps & { value?: DateRange | null defaultValue?: DateRange | null onValueChange?: (value: DateRange | null) => void } function DateRangeCalendar({ value, phone, calendarProps, onComplete, }: { value: DateRange | null phone: boolean calendarProps?: CalendarPassthrough onComplete: (range: DateRange) => void }) { const [draft, setDraft] = React.useState<DateRange | undefined>(undefined) const selected = draft ?? value ?? undefined return ( <Calendar {...calendarProps} mode="range" autoFocus numberOfMonths={phone ? 1 : 2} defaultMonth={value?.from ?? calendarProps?.defaultMonth} selected={selected} onSelect={(_, day) => { if (!draft) { setDraft({ from: day, to: day }) return } const start = draft.from ?? day const range = day < start ? { from: day, to: start } : { from: start, to: day } setDraft(undefined) onComplete(range) }} /> ) } function DateRangePicker({ value: valueProp, defaultValue = null, onValueChange, placeholder = "Pick a date range", title = "Select dates", locale = "en-US", calendarProps, ...props }: DateRangePickerProps) { const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue) const value = valueProp !== undefined ? valueProp : uncontrolledValue const formatter = React.useMemo( () => new Intl.DateTimeFormat(locale, { dateStyle: "medium" }), [locale] ) const setValue = (next: DateRange | null) => { if (valueProp === undefined) { setUncontrolledValue(next) } onValueChange?.(next) } const from = value?.from const to = value?.to ?? value?.from const label = from && to ? isSameDay(from, to) ? normalizeSpaces(formatter.format(from)) : normalizeSpaces(formatter.formatRange(from, to)) : undefined return ( <DatePickerShell {...props} placeholder={placeholder} title={title} label={label} hasValue={Boolean(from)} hiddenValue={ from && to ? `${toIsoDate(from)}/${toIsoDate(to)}` : undefined } onClear={() => setValue(null)} > {(close, phone) => ( <DateRangeCalendar value={value} phone={phone} calendarProps={calendarProps} onComplete={(range) => { setValue(range) close() }} /> )} </DatePickerShell> ) } export { DatePicker, DateRangePicker } export type { DatePickerProps, DateRangePickerProps }components/ui/calendar.tsx "use client" import * as React from "react" import { useDirection } from "@base-ui/react/direction-provider" import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp, } from "@tabler/icons-react" import { cn } from "cn" import { fromDateKey, getTodayKey, subscribeToday, toDateKey, useToday, } from "@/hooks/use-today" import { easeOut, easeSpring } from "@/lib/motion" import { addToRange, DayPicker, getDefaultClassNames, useDayPicker, type ChevronProps, type DateRange, type DayButtonProps, type DayEventHandler, type DayProps, type MonthCaptionProps, type MonthProps, type MonthsProps, type RootProps, type WeekNumberProps, type WeeksProps, } from "react-day-picker" import { Button, buttonVariants } from "@/components/ui/button" type CalendarContextValue = { id: string todayKey: string | undefined animate: boolean hovered: Date | undefined } const CalendarContext = React.createContext<CalendarContextValue | null>(null) const CalendarPreviewContext = React.createContext<DateRange | undefined>( undefined ) function useCalendarContext(part: string) { const context = React.useContext(CalendarContext) if (!context) { throw new Error(`<${part}> must be used within <Calendar>.`) } return context } function omit<T extends object, K extends keyof T>(value: T, ...keys: K[]) { const result = { ...value } for (const key of keys) { delete result[key] } return result as Omit<T, K> } function isSameDay(a: Date, b: Date) { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ) } function subscribeNothing() { return () => {} } function readServerToday(id: string) { if (typeof document === "undefined") { return getTodayKey() } const root = document.querySelector(`[data-calendar-id="${CSS.escape(id)}"]`) return root?.getAttribute("data-today") ?? getTodayKey() } function useCalendarToday(id: string, enabled: boolean) { const getServerSnapshot = React.useCallback(() => readServerToday(id), [id]) const todayKey = React.useSyncExternalStore( enabled ? subscribeToday : subscribeNothing, getTodayKey, getServerSnapshot ) const isHydrating = React.useSyncExternalStore( subscribeNothing, () => false, () => true ) const [initialKey] = React.useState(todayKey) const [remount, setRemount] = React.useState<boolean | undefined>(undefined) if (enabled && !isHydrating && remount === undefined) { setRemount(initialKey.slice(0, 7) !== todayKey.slice(0, 7)) } return { todayKey: enabled ? todayKey : undefined, remount: remount === true } } type CalendarProps = React.ComponentProps<typeof DayPicker> & { buttonVariant?: React.ComponentProps<typeof Button>["variant"] } function composeDayHandler<Event extends React.SyntheticEvent>( userHandler: DayEventHandler<Event> | undefined, internal: DayEventHandler<Event> | undefined ): DayEventHandler<Event> | undefined { if (!internal) { return userHandler } return (date, modifiers, event) => { userHandler?.(date, modifiers, event) if (!event.defaultPrevented) { internal(date, modifiers, event) } } } function Calendar({ className, classNames, showOutsideDays = true, captionLayout = "label", navLayout = "around", buttonVariant = "ghost", animate = true, formatters, components, dir, today, onDayMouseEnter, onDayMouseLeave, onDayFocus, onDayBlur, ...props }: CalendarProps) { const defaultClassNames = getDefaultClassNames() const id = React.useId() const direction = useDirection() const [hovered, setHovered] = React.useState<Date>() const managesToday = today === undefined && props.timeZone === undefined const { todayKey, remount } = useCalendarToday(id, managesToday) const isRange = props.mode === "range" const context = React.useMemo( () => ({ id, todayKey, animate, hovered }), [id, todayKey, animate, hovered] ) const track = React.useCallback<DayEventHandler<React.SyntheticEvent>>( (date, modifiers) => { setHovered(modifiers.disabled ? undefined : date) }, [] ) const clear = React.useCallback<DayEventHandler<React.SyntheticEvent>>( () => setHovered(undefined), [] ) return ( <CalendarContext.Provider value={context}> <DayPicker key={remount ? "client" : "server"} dir={dir ?? (direction === "rtl" ? "rtl" : undefined)} today={today ?? (todayKey ? fromDateKey(todayKey) : undefined)} showOutsideDays={showOutsideDays} captionLayout={captionLayout} navLayout={navLayout} onDayMouseEnter={composeDayHandler( onDayMouseEnter, isRange ? track : undefined )} onDayMouseLeave={composeDayHandler( onDayMouseLeave, isRange ? clear : undefined )} onDayFocus={composeDayHandler(onDayFocus, isRange ? track : undefined)} onDayBlur={composeDayHandler(onDayBlur, isRange ? clear : undefined)} className={cn( "group/calendar bg-background p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(9)] in-data-[slot=alert-dialog-content]:bg-transparent in-data-[slot=card-content]:bg-transparent in-data-[slot=dialog-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent in-data-[slot=sheet-content]:bg-transparent pointer-coarse:[--cell-size:--spacing(11)]", className )} formatters={{ formatMonthDropdown: (month, dateLib) => dateLib ? dateLib.format(month, "LLL") : toDateKey(month), ...formatters, }} classNames={{ root: cn("w-fit max-w-full", defaultClassNames.root), months: cn( "relative flex flex-col gap-4 sm:flex-row", defaultClassNames.months ), month: cn( "relative flex w-[calc(var(--cell-size)*7)] max-w-full min-w-0 flex-col gap-3 group-data-week-numbers/calendar:w-[calc(var(--cell-size)*8)]", defaultClassNames.month ), nav: cn( "pointer-events-none absolute inset-x-0 top-0 z-10 flex h-(--cell-size) items-center justify-between gap-1 *:pointer-events-auto", defaultClassNames.nav ), button_previous: cn( buttonVariants({ variant: buttonVariant, size: "icon" }), "size-(--cell-size) rounded-(--cell-radius) p-0 aria-disabled:pointer-events-none aria-disabled:opacity-50", navLayout === "around" && "absolute start-0 top-0 z-10", defaultClassNames.button_previous ), button_next: cn( buttonVariants({ variant: buttonVariant, size: "icon" }), "size-(--cell-size) rounded-(--cell-radius) p-0 aria-disabled:pointer-events-none aria-disabled:opacity-50", navLayout === "around" && "absolute end-0 top-0 z-10", 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 text-sm font-medium", defaultClassNames.dropdowns ), dropdown_root: cn( "relative rounded-(--cell-radius) transition-[background-color,box-shadow] duration-150 ease-out-quint has-focus-visible:ring-3 has-focus-visible:ring-focus-ring [@media(hover:hover)]:has-[select:hover]:bg-muted", defaultClassNames.dropdown_root ), dropdown: cn( "absolute inset-0 cursor-pointer bg-popover text-popover-foreground opacity-0", defaultClassNames.dropdown ), caption_label: cn( "font-medium select-none", captionLayout === "label" ? "truncate text-sm" : "flex h-8 items-center gap-1 rounded-(--cell-radius) ps-2 pe-1 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", defaultClassNames.caption_label ), month_grid: cn( "w-full table-fixed border-collapse", defaultClassNames.month_grid ), weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( "w-(--cell-size) min-w-0 text-xs font-normal text-muted-foreground select-none", defaultClassNames.weekday ), week: cn("mt-1 flex w-full", defaultClassNames.week), week_number_header: cn( "w-(--cell-size) shrink-0 select-none", defaultClassNames.week_number_header ), week_number: cn( "text-xs font-normal text-muted-foreground select-none", defaultClassNames.week_number ), day: cn( "group/day relative aspect-square w-(--cell-size) min-w-0 p-0 text-center transition-[background-color] duration-150 ease-out-quint select-none data-preview:bg-muted/60 data-[preview=end]:rounded-e-(--cell-radius) data-[preview=start]:rounded-s-(--cell-radius) data-[preview=end]:data-selected:rounded-s-none data-[preview=start]:data-selected:rounded-e-none [&:is([data-preview],[data-range-middle]):first-of-type]:rounded-s-(--cell-radius) [&:is([data-preview],[data-range-middle]):has(+[data-duplicate])]:rounded-e-(--cell-radius) [&:is([data-preview],[data-range-middle]):last-of-type]:rounded-e-(--cell-radius) [[data-duplicate]+&:is([data-preview],[data-range-middle])]:rounded-s-(--cell-radius)", defaultClassNames.day ), range_start: cn( "rounded-s-(--cell-radius) bg-muted", defaultClassNames.range_start ), range_middle: cn("bg-muted", defaultClassNames.range_middle), range_end: cn( "rounded-e-(--cell-radius) bg-muted", defaultClassNames.range_end ), today: cn(defaultClassNames.today), outside: cn(defaultClassNames.outside), disabled: cn(defaultClassNames.disabled), hidden: cn("invisible", defaultClassNames.hidden), footer: cn( "pt-3 text-sm text-pretty text-muted-foreground", defaultClassNames.footer ), ...classNames, }} components={{ Root: CalendarRoot, Chevron: CalendarChevron, Months: CalendarMonths, Month: CalendarMonth, MonthCaption: CalendarMonthCaption, Weeks: CalendarWeeks, Day: CalendarDay, DayButton: CalendarDayButton, WeekNumber: CalendarWeekNumber, ...components, }} {...props} /> </CalendarContext.Provider> ) } function CalendarRoot({ className, rootRef, onKeyDownCapture, ...props }: RootProps) { const { id, todayKey } = useCalendarContext("CalendarRoot") return ( <div ref={rootRef} className={className} onKeyDownCapture={(event) => { event.currentTarget.dataset.keyboardAt = String(performance.now()) onKeyDownCapture?.(event) }} {...props} data-slot="calendar" data-calendar-id={id} data-today={todayKey} /> ) } function CalendarChevron({ className, orientation, ...props }: ChevronProps) { const { dayPickerProps } = useDayPicker() const mirrored = dayPickerProps.navLayout === "around" && dayPickerProps.dir === "rtl" const icons = { left: IconChevronLeft, right: IconChevronRight, up: IconChevronUp, down: IconChevronDown, } const Icon = icons[orientation ?? "left"] return ( <Icon aria-hidden className={cn( "size-4", !mirrored && (orientation === "left" || orientation === "right") && "rtl:-scale-x-100", className )} style={props.style} /> ) } function CalendarMonths({ className, ...props }: MonthsProps) { const { hovered } = useCalendarContext("CalendarMonths") const { selected, dayPickerProps } = useDayPicker() const preview = React.useMemo(() => { if (dayPickerProps.mode !== "range" || !hovered) { return undefined } const range = selected as DateRange | undefined if (!range?.from || (range.to && !isSameDay(range.from, range.to))) { return undefined } const next = addToRange( hovered, range, dayPickerProps.min, dayPickerProps.max, dayPickerProps.required ) if (!next?.from || !next.to || isSameDay(next.from, next.to)) { return undefined } return next }, [dayPickerProps, hovered, selected]) return ( <CalendarPreviewContext.Provider value={preview}> <div className={className} {...props} data-slot="calendar-months" /> </CalendarPreviewContext.Provider> ) } const bottomClip = "inset(-1rem -1rem 0 -1rem)" function layoutSize(element: HTMLElement) { const style = getComputedStyle(element) const width = parseFloat(style.width) const height = parseFloat(style.height) if (Number.isFinite(width) && Number.isFinite(height)) { return { width, height } } const rect = element.getBoundingClientRect() return { width: rect.width, height: rect.height } } function CalendarMonth({ calendarMonth, className, ...rest }: MonthProps) { const props = omit(rest, "displayIndex") const { animate } = useCalendarContext("CalendarMonth") const elementRef = React.useRef<HTMLDivElement>(null) const time = calendarMonth.date.getTime() const previousTimeRef = React.useRef(time) const animationsRef = React.useRef<Animation[]>([]) const heightRef = React.useRef(0) React.useLayoutEffect(() => { const element = elementRef.current if (!element) { return } heightRef.current = layoutSize(element).height if (typeof ResizeObserver !== "function") { return } const observer = new ResizeObserver(() => { if (animationsRef.current.every((a) => a.playState !== "running")) { heightRef.current = layoutSize(element).height } }) observer.observe(element) return () => observer.disconnect() }, []) React.useLayoutEffect(() => { const element = elementRef.current const previous = previousTimeRef.current previousTimeRef.current = time if ( !animate || !element || previous === time || typeof element.animate !== "function" ) { return } const reduceMotion = typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches const keyboardAt = Number( element.closest<HTMLElement>("[data-slot=calendar]")?.dataset .keyboardAt ?? -Infinity ) const fromKeyboard = performance.now() - keyboardAt < 100 const rtl = getComputedStyle(element).direction === "rtl" const sign = (time > previous ? 1 : -1) * (rtl ? -1 : 1) const distance = Math.min(32, layoutSize(element).width * 0.1) const weeks = element.querySelector<HTMLElement>( "[data-slot=calendar-weeks]" ) const caption = element.querySelector<HTMLElement>( "[data-slot=calendar-month-caption]" ) const interrupted = animationsRef.current.some( (a) => a.playState === "running" ) const fromHeight = interrupted ? layoutSize(element).height : heightRef.current const fromOpacity = interrupted && weeks ? parseFloat(getComputedStyle(weeks).opacity) / 2 : 0 for (const running of animationsRef.current) { running.cancel() } const toHeight = layoutSize(element).height heightRef.current = toHeight const slide = (target: HTMLElement, offset: number, duration: number) => target.animate( reduceMotion || fromKeyboard ? [{ opacity: fromOpacity }, { opacity: 1 }] : [ { translate: `${sign * offset}px 0`, opacity: fromOpacity }, { translate: "0 0", opacity: 1 }, ], { duration: reduceMotion ? 150 : fromKeyboard ? 120 : duration, easing: easeOut, } ) const next: Animation[] = [] if (weeks) { next.push(slide(weeks, distance, 280)) } if (caption) { next.push(slide(caption, distance * 0.4, 220)) } if (fromHeight > 0 && Math.abs(fromHeight - toHeight) > 0.5) { next.push( element.animate( [ { height: `${fromHeight}px`, clipPath: bottomClip }, { height: `${toHeight}px`, clipPath: bottomClip }, ], { duration: reduceMotion ? 0 : 300, easing: easeSpring } ) ) } animationsRef.current = next }, [animate, time]) React.useEffect(() => { const animations = animationsRef return () => { for (const running of animations.current) { running.cancel() } } }, []) return ( <div ref={elementRef} className={className} {...props} data-slot="calendar-month" /> ) } function CalendarMonthCaption({ className, ...rest }: MonthCaptionProps) { const props = omit(rest, "calendarMonth", "displayIndex") return ( <div className={className} {...props} data-slot="calendar-month-caption" /> ) } function CalendarWeeks({ className, ...props }: WeeksProps) { return <tbody className={className} {...props} data-slot="calendar-weeks" /> } function CalendarDay({ day, modifiers, className, children, ...props }: DayProps) { const preview = React.useContext(CalendarPreviewContext) const { months } = useDayPicker() const duplicate = day.outside && months.length > 1 && months.some( (month) => month.date.getFullYear() === day.date.getFullYear() && month.date.getMonth() === day.date.getMonth() ) if (duplicate) { return ( <td className={cn(className, "invisible")} {...props} aria-hidden data-slot="calendar-day" data-duplicate="" /> ) } let state: "start" | "middle" | "end" | undefined if (preview?.from && preview.to && !modifiers.hidden) { const date = day.date if (isSameDay(date, preview.from)) { state = "start" } else if (isSameDay(date, preview.to)) { state = "end" } else if (date > preview.from && date < preview.to) { state = "middle" } } return ( <td className={className} {...props} data-slot="calendar-day" data-preview={state} data-range-middle={modifiers.range_middle ? "" : undefined} > {children} </td> ) } function CalendarDayButton({ className, day, modifiers, ...props }: DayButtonProps) { const ref = React.useRef<HTMLButtonElement>(null) React.useEffect(() => { if (modifiers.focused) { ref.current?.focus() } }, [modifiers.focused]) const rangeStart = Boolean(modifiers.range_start) const rangeEnd = Boolean(modifiers.range_end) const rangeMiddle = Boolean(modifiers.range_middle) const selectedSingle = Boolean(modifiers.selected) && !rangeStart && !rangeEnd && !rangeMiddle return ( <button ref={ref} className={cn( buttonVariants({ variant: "ghost", size: "icon" }), "relative size-full rounded-(--cell-radius) p-0 font-normal tabular-nums group-data-outside/day:text-muted-foreground focus-visible:z-10 data-[range-end]:bg-primary data-[range-end]:text-primary-foreground data-[range-end]:hover:bg-primary/90 data-[range-middle]:rounded-none data-[range-middle]:hover:rounded-(--cell-radius) data-[range-middle]:hover:bg-foreground/6 data-[range-start]:bg-primary data-[range-start]:text-primary-foreground data-[range-start]:hover:bg-primary/90 data-[selected-single]:bg-primary data-[selected-single]:text-primary-foreground data-[selected-single]:hover:bg-primary/90 data-[today]:bg-info/12 data-[today]:font-semibold data-[today]:text-info data-[today]:hover:bg-info/15 data-[today]:data-[range-end]:bg-info data-[today]:data-[range-end]:text-info-foreground data-[today]:data-[range-end]:hover:bg-info/90 data-[today]:data-[range-middle]:bg-transparent data-[today]:data-[range-middle]:hover:bg-foreground/6 data-[today]:data-[range-start]:bg-info data-[today]:data-[range-start]:text-info-foreground data-[today]:data-[range-start]:hover:bg-info/90 data-[today]:data-[selected-single]:bg-info data-[today]:data-[selected-single]:text-info-foreground data-[today]:data-[selected-single]:hover:bg-info/90 forced-colors:data-[range-end]:outline-2 forced-colors:data-[range-end]:-outline-offset-2 forced-colors:data-[range-end]:outline-solid forced-colors:data-[range-start]:outline-2 forced-colors:data-[range-start]:-outline-offset-2 forced-colors:data-[range-start]:outline-solid forced-colors:data-[selected-single]:outline-2 forced-colors:data-[selected-single]:-outline-offset-2 forced-colors:data-[selected-single]:outline-solid", className )} {...props} data-slot="calendar-day-button" data-day={day.isoDate} data-today={modifiers.today && !modifiers.outside ? "" : undefined} data-selected-single={selectedSingle ? "" : undefined} data-range-start={rangeStart ? "" : undefined} data-range-end={rangeEnd ? "" : undefined} data-range-middle={rangeMiddle ? "" : undefined} /> ) } function CalendarWeekNumber({ children, className, ...rest }: WeekNumberProps) { const props = omit(rest, "week") return ( <th className={className} {...props} data-slot="calendar-week-number"> <div className="flex size-(--cell-size) items-center justify-center text-center"> {children} </div> </th> ) } export { Calendar, CalendarDayButton, useToday } export type { CalendarProps }components/ui/popover.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Popover as PopoverPrimitive } from "@base-ui/react/popover" import { cn } from "cn" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } function Popover<Payload>({ onOpenChange, children, ...props }: PopoverPrimitive.Root.Props<Payload>) { const inherited = useDirection() const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const direction = triggerDirection === "rtl" ? "rtl" : inherited return ( <DirectionProvider direction={direction}> <PopoverPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (open && !eventDetails.isCanceled && eventDetails.trigger) { setTriggerDirection(readDirection(eventDetails.trigger)) } }} {...props} > {children} </PopoverPrimitive.Root> </DirectionProvider> ) } function PopoverTrigger<Payload>( props: PopoverPrimitive.Trigger.Props<Payload> ) { return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> } function PopoverPortal(props: PopoverPrimitive.Portal.Props) { return <PopoverPrimitive.Portal data-slot="popover-portal" {...props} /> } const tracking = 80 function useAnimatedHeight() { return React.useCallback((sizer: HTMLDivElement | null) => { const popup = sizer?.parentElement if (!sizer || !popup || typeof ResizeObserver === "undefined") { return } const positioner = popup.parentElement if (positioner && !positioner.hasAttribute("dir")) { const trigger = popup.id ? popup.ownerDocument.querySelector( `[aria-controls="${CSS.escape(popup.id)}"]` ) : null if (readDirection(trigger) === "rtl") { positioner.setAttribute("dir", "rtl") } } let last = 0 let settle: ReturnType<typeof setTimeout> | undefined let sized: ReturnType<typeof setTimeout> | undefined const endSizing = (event?: TransitionEvent) => { if ( event && (event.target !== popup || event.propertyName !== "height") ) { return } clearTimeout(sized) delete popup.dataset.sizing } popup.addEventListener("transitionend", endSizing) popup.addEventListener("transitioncancel", endSizing) const observer = new ResizeObserver(([entry]) => { const now = performance.now() const continuous = now - last < tracking if (continuous) { popup.dataset.resizing = "" } last = now clearTimeout(settle) settle = setTimeout(() => { delete popup.dataset.resizing }, tracking) if (popup.style.height && !continuous) { popup.dataset.sizing = "" clearTimeout(sized) sized = setTimeout(endSizing, 300) } const style = getComputedStyle(popup) const chrome = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth) const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height popup.style.height = `${height + chrome}px` }) observer.observe(sizer) return () => { observer.disconnect() clearTimeout(settle) clearTimeout(sized) popup.removeEventListener("transitionend", endSizing) popup.removeEventListener("transitioncancel", endSizing) delete popup.dataset.resizing delete popup.dataset.sizing popup.style.height = "" } }, []) } type PopoverContentProps = PopoverPrimitive.Popup.Props & Pick< PopoverPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "collisionPadding" | "collisionAvoidance" | "collisionBoundary" | "sticky" | "positionMethod" > & { portalProps?: Omit<PopoverPrimitive.Portal.Props, "children"> } function PopoverContent({ className, children, side = "bottom", align = "center", sideOffset = 6, alignOffset = 0, anchor, collisionPadding = 8, collisionAvoidance, collisionBoundary, sticky, positionMethod, portalProps, ...props }: PopoverContentProps) { const sizerRef = useAnimatedHeight() const direction = useDirection() return ( <PopoverPrimitive.Portal {...portalProps}> <PopoverPrimitive.Positioner data-slot="popover-positioner" dir={direction === "rtl" ? "rtl" : undefined} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} collisionPadding={collisionPadding} collisionAvoidance={collisionAvoidance} collisionBoundary={collisionBoundary} sticky={sticky} positionMethod={positionMethod} className="isolate z-50 outline-none focus-visible:outline-hidden" > <PopoverPrimitive.Popup data-slot="popover-content" className={mergeClassName( "relative flex max-h-(--available-height) w-72 max-w-(--available-width) origin-(--transform-origin) flex-col gap-2.5 overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale,height] duration-150 ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-100 data-ending-style:data-instant:transition-none data-starting-style:opacity-0 data-[resizing]:transition-[opacity,scale] data-[sizing]:overflow-y-hidden motion-safe:data-ending-style:scale-96 motion-safe:data-starting-style:scale-96 motion-reduce:transition-opacity forced-colors:border", className )} {...props} > <div ref={sizerRef} data-slot="popover-content-sizer" className="flex min-w-0 shrink-0 flex-col gap-[inherit]" > {children} </div> </PopoverPrimitive.Popup> </PopoverPrimitive.Positioner> </PopoverPrimitive.Portal> ) } function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="popover-header" className={cn("flex min-w-0 flex-col gap-0.5 text-sm", className)} {...props} /> ) } function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { return ( <PopoverPrimitive.Title data-slot="popover-title" className={mergeClassName( "text-sm font-medium text-pretty wrap-anywhere", className )} {...props} /> ) } function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) { return ( <PopoverPrimitive.Description data-slot="popover-description" className={mergeClassName( "text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } function PopoverClose(props: PopoverPrimitive.Close.Props) { return <PopoverPrimitive.Close data-slot="popover-close" {...props} /> } const createPopoverHandle = PopoverPrimitive.createHandle export { Popover, PopoverTrigger, PopoverPortal, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, PopoverClose, createPopoverHandle, } export type { PopoverContentProps }components/ui/sheet.tsx "use client" import * as React from "react" import { Drawer as SheetPrimitive } from "@base-ui/react/drawer" import { IconX } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } const DialogLayerContext = React.createContext(0) function DialogLayerProvider({ children }: { children: React.ReactNode }) { const depth = React.useContext(DialogLayerContext) return ( <DialogLayerContext.Provider value={depth + 1}> {children} </DialogLayerContext.Provider> ) } function useDialogLayer() { return React.useContext(DialogLayerContext) } type SwipeDirection = "up" | "down" | "left" | "right" const SheetRootContext = React.createContext< ((direction: SwipeDirection) => void) | null >(null) function Sheet<Payload>({ swipeDirection, ...props }: SheetPrimitive.Root.Props<Payload>) { const [contentDirection, setContentDirection] = React.useState<SwipeDirection>("right") return ( <DialogLayerProvider> <SheetRootContext.Provider value={setContentDirection}> <SheetPrimitive.Root data-slot="sheet" swipeDirection={swipeDirection ?? contentDirection} {...props} /> </SheetRootContext.Provider> </DialogLayerProvider> ) } function SheetTrigger<Payload>(props: SheetPrimitive.Trigger.Props<Payload>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> } function SheetPortal(props: SheetPrimitive.Portal.Props) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetClose(props: SheetPrimitive.Close.Props) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> } const sheetOverlayVariants = cva( "fixed inset-0 z-50 min-h-dvh opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:duration-200 sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:ease-out-quint sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:data-ending-style:duration-150 [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { nested: { false: "bg-black/40 dark:bg-black/60", true: "bg-black/20 dark:bg-black/30", }, }, defaultVariants: { nested: false, }, } ) function SheetOverlay({ className, forceRender, ...props }: SheetPrimitive.Backdrop.Props) { const nested = useDialogLayer() > 1 return ( <SheetPrimitive.Backdrop data-slot="sheet-overlay" data-nested={nested ? "" : undefined} forceRender={forceRender ?? nested} className={mergeClassName(sheetOverlayVariants({ nested }), className)} {...props} /> ) } const sheetContentVariants = cva( "fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-none bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,translate,scale,opacity,top] duration-300 ease-drawer outline-none [--bleed:3rem] focus-visible:outline-hidden data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-nested-dialog-open:scale-[calc(1-0.02*var(--nested-dialogs))] data-nested-drawer-open:scale-[calc(1-0.02*var(--nested-drawers))] data-swiping:cursor-grabbing data-swiping:select-none motion-reduce:transition-opacity motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 rtl:[--sheet-dir:-1] forced-colors:border [&[data-swiping]:not([data-ending-style])]:duration-0", { variants: { side: { top: "inset-x-0 top-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-b-2xl pt-(--bleed) [--sheet-close-top:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-y-[calc(-100%+var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(-100%+var(--bleed))]", right: "inset-y-0 end-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-s-2xl pe-(--bleed) [--sheet-close-end:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] sm:max-w-[calc(24rem+var(--bleed))]", bottom: "inset-x-0 bottom-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl pb-[calc(var(--bleed)+env(safe-area-inset-bottom))] [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-y-[calc(100%-var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(100%-var(--bleed))]", left: "inset-y-0 start-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-e-2xl ps-(--bleed) [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] sm:max-w-[calc(24rem+var(--bleed))]", }, }, defaultVariants: { side: "right", }, } ) const sheetHandleVariants = cva( "group/sheet-handle absolute z-10 flex cursor-grab touch-none items-center justify-center active:cursor-grabbing", { variants: { side: { top: "inset-x-0 bottom-0 h-6", right: "inset-y-0 start-0 w-6", bottom: "inset-x-0 top-0 h-6", left: "inset-y-0 end-0 w-6", }, }, } ) const sheetHandleBarVariants = cva( "rounded-full bg-muted-foreground/30 transition-colors duration-150 group-hover/sheet-handle:bg-muted-foreground/50 group-active/sheet-handle:bg-muted-foreground/60", { variants: { side: { top: "h-1 w-10", right: "h-10 w-1", bottom: "h-1 w-10", left: "h-10 w-1", }, }, } ) type SheetSide = NonNullable<VariantProps<typeof sheetContentVariants>["side"]> const SheetContentContext = React.createContext<{ showCloseButton: boolean }>({ showCloseButton: false }) function resolveSwipeDirection(side: SheetSide, rtl: boolean): SwipeDirection { if (side === "top") { return "up" } if (side === "bottom") { return "down" } return (side === "right") !== rtl ? "right" : "left" } function SheetContent({ className, children, side = "right", showCloseButton = true, dir, ...props }: SheetPrimitive.Popup.Props & VariantProps<typeof sheetContentVariants> & { showCloseButton?: boolean }) { const resolvedSide = side ?? "right" const setSwipeDirection = React.useContext(SheetRootContext) const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton]) React.useEffect(() => { const rtl = (dir ?? getComputedStyle(document.documentElement).direction) === "rtl" setSwipeDirection?.(resolveSwipeDirection(resolvedSide, rtl)) }, [dir, resolvedSide, setSwipeDirection]) return ( <SheetContentContext.Provider value={context}> <SheetPortal> <SheetOverlay /> <SheetPrimitive.Viewport data-slot="sheet-viewport" dir={dir} className="fixed inset-0 z-50" > <SheetPrimitive.Popup data-slot="sheet-content" data-side={resolvedSide} dir={dir} className={mergeClassName( sheetContentVariants({ side: resolvedSide }), className )} {...props} > <div data-slot="sheet-handle" aria-hidden="true" className={sheetHandleVariants({ side: resolvedSide })} > <div className={sheetHandleBarVariants({ side: resolvedSide })} /> </div> <SheetPrimitive.Content data-slot="sheet-inner" className="flex min-h-0 flex-1 flex-col gap-4" > {children} </SheetPrimitive.Content> {showCloseButton ? ( <SheetPrimitive.Close data-slot="sheet-close-button" aria-label="Close" render={<Button variant="ghost" size="icon-sm" />} className="absolute end-(--sheet-close-end,0.75rem) top-(--sheet-close-top,0.75rem) z-20 rounded-(--sheet-close-radius,var(--radius-md)) text-muted-foreground hover:text-foreground" > <IconX /> </SheetPrimitive.Close> ) : null} </SheetPrimitive.Popup> </SheetPrimitive.Viewport> </SheetPortal> </SheetContentContext.Provider> ) } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { const { showCloseButton } = React.useContext(SheetContentContext) return ( <div data-slot="sheet-header" className={cn( "flex flex-col gap-1.5 px-6 pt-6", showCloseButton && "pe-12", className )} {...props} /> ) } function SheetBody({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-body" className={cn( "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 wrap-anywhere", className )} {...props} /> ) } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn( "mt-auto flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10", className )} {...props} /> ) } function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={mergeClassName( "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere", className )} {...props} /> ) } function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={mergeClassName( "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground", className )} {...props} /> ) } const createSheetHandle = SheetPrimitive.createHandle export { Sheet, SheetTrigger, SheetPortal, SheetOverlay, SheetContent, SheetHeader, SheetBody, SheetFooter, SheetTitle, SheetDescription, SheetClose, sheetContentVariants, sheetOverlayVariants, createSheetHandle, DialogLayerProvider, useDialogLayer, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"const [date, setDate] = React.useState<Date | null>(null)
<DatePicker value={date} onValueChange={setDate} />On screens narrower than 640px the calendar opens in a bottom sheet instead of a popover, so days stay large enough to tap.
Controlled#
Pass value and onValueChange. Cleared values are null. The calendar always opens on the month of the selected date, and picking a day closes it and returns focus to the button.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { DatePicker } from "@/components/ui/date-picker"
function daysFromToday(days: number) {
const date = new Date()
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() + days)
return date
}
const isoDay = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
export function DatePickerControlled() {
const [date, setDate] = React.useState<Date | null>(null)
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<DatePicker value={date} onValueChange={setDate} clearable />
<Button variant="ghost" onClick={() => setDate(daysFromToday(0))}>
Today
</Button>
<Button variant="ghost" onClick={() => setDate(daysFromToday(7))}>
In a week
</Button>
<output className="text-sm text-muted-foreground tabular-nums">
{date ? isoDay.format(date) : "null"}
</output>
</div>
)
}Range#
<DateRangePicker /> shows two months on larger screens. The first click always starts a new range, even when one is set, and the second click finishes it in either order and closes the picker.
"use client"
import * as React from "react"
import type { DateRange } from "react-day-picker"
import { DateRangePicker } from "@/components/ui/date-picker"
const isoDay = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
export function DatePickerRange() {
const [range, setRange] = React.useState<DateRange | null>(null)
return (
<div className="flex flex-col items-center gap-2">
<DateRangePicker value={range} onValueChange={setRange} clearable />
<output className="text-sm text-muted-foreground tabular-nums">
{range?.from && range.to
? `${isoDay.format(range.from)} → ${isoDay.format(range.to)}`
: "null"}
</output>
</div>
)
}Disabled days#
Pass any react-day-picker matcher as calendarProps.disabled. Here only future weekdays can be picked.
"use client"
import * as React from "react"
import { DatePicker } from "@/components/ui/date-picker"
function tomorrow() {
const date = new Date()
date.setHours(0, 0, 0, 0)
date.setDate(date.getDate() + 1)
return date
}
export function DatePickerDisabledDays() {
const [firstDay] = React.useState(tomorrow)
return (
<DatePicker
placeholder="Delivery date"
calendarProps={{
disabled: [{ before: firstDay }, { dayOfWeek: [0, 6] }],
}}
/>
)
}Month and year dropdowns#
For far-away dates like a birthday, set captionLayout: "dropdown" with a startMonth and endMonth. Label the trigger with a <label /> pointing at its id.
import { DatePicker } from "@/components/ui/date-picker"
export function DatePickerDropdowns() {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor="birthday" className="text-sm font-medium">
Date of birth
</label>
<DatePicker
id="birthday"
placeholder="Select your birthday"
calendarProps={{
captionLayout: "dropdown",
startMonth: new Date(1920, 0),
endMonth: new Date(),
disabled: { after: new Date() },
}}
/>
</div>
)
}In a form#
With name, a single date is submitted as 2026-10-14 and a range as 2026-10-20/2026-10-24. Empty pickers submit an empty string.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"
export function DatePickerForm() {
const [submitted, setSubmitted] = React.useState<string>()
return (
<form
className="flex flex-col items-start gap-3"
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
setSubmitted(JSON.stringify(Object.fromEntries(data)))
}}
>
<DatePicker name="due" defaultValue={new Date(2026, 9, 14)} />
<DateRangePicker
name="stay"
defaultValue={{
from: new Date(2026, 9, 20),
to: new Date(2026, 9, 24),
}}
/>
<Button type="submit">Submit</Button>
<output className="text-sm break-all text-muted-foreground">
{submitted ?? "Not submitted"}
</output>
</form>
)
}Disabled#
A disabled trigger can still show a value.
import { DatePicker } from "@/components/ui/date-picker"
export function DatePickerDisabled() {
return (
<div className="flex flex-wrap justify-center gap-2">
<DatePicker disabled />
<DatePicker disabled defaultValue={new Date(2026, 0, 1)} />
</div>
)
}Narrow container#
The trigger never grows past its container. Long labels truncate instead of wrapping.
import { DateRangePicker } from "@/components/ui/date-picker"
export function DatePickerNarrow() {
return (
<div className="w-40">
<DateRangePicker
defaultValue={{
from: new Date(2026, 0, 28),
to: new Date(2026, 11, 3),
}}
/>
</div>
)
}Inside a sheet#
The picker layers above the sheet, and Escape closes only the picker.
import { Button } from "@/components/ui/button"
import { DateRangePicker } from "@/components/ui/date-picker"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
export function DatePickerInSheet() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
Edit booking
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit booking</SheetTitle>
<SheetDescription>
The picker opens above the sheet and Escape closes only the picker.
</SheetDescription>
</SheetHeader>
<SheetBody>
<DateRangePicker />
</SheetBody>
</SheetContent>
</Sheet>
)
}Right to left#
Pass locale for the trigger label and a react-day-picker locale with dir: "rtl" in calendarProps for the calendar. Arrow keys follow the direction.
"use client"
import { arSA } from "react-day-picker/locale"
import { DatePicker, DateRangePicker } from "@/components/ui/date-picker"
export function DatePickerRtl() {
return (
<div dir="rtl" className="flex flex-wrap justify-center gap-2">
<DatePicker
locale="ar"
placeholder="اختر تاريخًا"
title="اختر تاريخًا"
clearLabel="مسح"
clearable
defaultValue={new Date(2026, 9, 2)}
calendarProps={{ locale: arSA, dir: "rtl" }}
/>
<DateRangePicker
locale="ar"
placeholder="اختر فترة"
title="اختر فترة"
calendarProps={{ locale: arSA, dir: "rtl" }}
/>
</div>
)
}| Key | Action |
|---|---|
| EnterSpace | On the trigger, opens the picker and focuses the selected day, or today. On a day, selects it. |
| ←→ | Moves to the previous or next day. |
| ↑↓ | Moves to the same day in the previous or next week. |
| Page UpPage Down | Moves to the previous or next month. |
| ShiftPage Up | Moves to the previous year. Shift Page Down moves to the next. |
| HomeEnd | Moves to the start or end of the week. |
| Esc | Closes the picker and returns focus to the trigger. |
- The popover is labelled with
title, and the bottom sheet shows it as a heading. Translate it along withplaceholder. - The trigger has no visible label of its own. Pair it with a
<label />or passaria-label. - Disabled days are skipped by the keyboard and announced as unavailable.
Both pickers share the props below and render a <Button /> trigger with a <Calendar /> in a popover or bottom sheet.
| Prop | Type | Default |
|---|---|---|
value | Date | null | – |
defaultValue | Date | null | null |
onValueChange | (value: Date | null) => void | – |
placeholder | string | "Pick a date" |
titleAccessible name of the popover, heading of the sheet. | string | "Select a date" |
open | boolean | – |
defaultOpen | boolean | false |
onOpenChange | (open: boolean) => void | – |
localeFormats the trigger label with Intl.DateTimeFormat. Pass a react-day-picker locale in calendarProps to translate the calendar too. | string | "en-US" |
clearableAdds a Clear button below the calendar once there is a value. | boolean | false |
clearLabel | string | "Clear" |
nameRenders a hidden input so the value is submitted with its form. | string | – |
calendarPropsPassed to the calendar, except mode, selected, onSelect, required, numberOfMonths and autoFocus. Use it for disabled days, captionLayout, startMonth, endMonth, locale and dir. | CalendarProps | – |
variantThe trigger is a Button, and accepts its other props too. | Button variant | "outline" |
disabled | boolean | false |
classNameApplied to the trigger, which is w-60 by default. | string | – |
| Prop | Type | Default |
|---|---|---|
value | { from: Date; to?: Date } | null | – |
defaultValue | { from: Date; to?: Date } | null | null |
onValueChangeCalled once the second day is picked. | (value: DateRange | null) => void | – |
placeholder | string | "Pick a date range" |
title | string | "Select dates" |
open | boolean | – |
defaultOpen | boolean | false |
onOpenChange | (open: boolean) => void | – |
localeFormats the trigger label with Intl.DateTimeFormat. Pass a react-day-picker locale in calendarProps to translate the calendar too. | string | "en-US" |
clearableAdds a Clear button below the calendar once there is a value. | boolean | false |
clearLabel | string | "Clear" |
nameRenders a hidden input so the value is submitted with its form. | string | – |
calendarPropsPassed to the calendar, except mode, selected, onSelect, required, numberOfMonths and autoFocus. Use it for disabled days, captionLayout, startMonth, endMonth, locale and dir. | CalendarProps | – |
variantThe trigger is a Button, and accepts its other props too. | Button variant | "outline" |
disabled | boolean | false |
classNameApplied to the trigger, which is w-60 by default. | string | – |
| Attribute | Description |
|---|---|
data-slot="date-picker-trigger" | The trigger button. |
data-empty | Present on the trigger while there is no value. Shows the placeholder in muted text. |
data-popup-open | Present on the trigger while the picker is open. |
data-slot="date-picker-content" | Wraps the calendar in the popover or sheet. |
data-slot="date-picker-clear" | The Clear button. |