A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client"
import * as React from "react"
import { format } from "date-fns"
import { Calendar } from "@/components/ui/calendar"
export function CalendarDemo() {
const [date, setDate] = React.useState<Date>()
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
footer={date ? `Selected ${format(date, "PPP")}.` : "Pick a day."}
/>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/calendar.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 react-day-picker @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
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/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 { Calendar } from "@/components/ui/calendar"const [date, setDate] = React.useState<Date>()
<Calendar mode="single" selected={date} onSelect={setDate} />Calendar wraps react-day-picker’s <DayPicker />, so every DayPicker prop works as documented on daypicker.dev. HextaUI adds styling, month transitions, a range preview and a hydration-safe today.
Range#
With mode="range", after the first click hovering or focusing a day previews the range the next click will select. numberOfMonths shows months side by side, stacked on narrow screens.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client"
import * as React from "react"
import { format } from "date-fns"
import type { DateRange } from "react-day-picker"
import { Calendar } from "@/components/ui/calendar"
export function CalendarRange() {
const [range, setRange] = React.useState<DateRange>()
return (
<div className="max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="range"
numberOfMonths={2}
selected={range}
onSelect={setRange}
footer={
range?.from && range.to
? `${format(range.from, "PP")} – ${format(range.to, "PP")}`
: range?.from
? "Pick an end date."
: "Pick a start date."
}
/>
</div>
)
}Range limits#
min and max limit the range length in days. With excludeDisabled, a range that would include a disabled day starts over instead.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { Calendar } from "@/components/ui/calendar"
export function CalendarRangeLimits() {
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="range"
min={2}
max={7}
disabled={{ dayOfWeek: [0, 6] }}
excludeDisabled
footer="Weekdays only, 2–7 days."
/>
</div>
)
}Multiple#
mode="multiple" toggles individual days. max caps how many can be selected.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { Calendar } from "@/components/ui/calendar"
export function CalendarMultiple() {
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar mode="multiple" max={5} footer="Pick up to five days." />
</div>
)
}Month and year dropdowns#
captionLayout="dropdown" replaces the caption with native selects, so phones get their own picker. Set startMonth and endMonth to bound the years.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
import { Calendar } from "@/components/ui/calendar"
export function CalendarDropdowns() {
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="single"
captionLayout="dropdown"
startMonth={new Date(1940, 0)}
endMonth={new Date(2035, 11)}
defaultMonth={new Date(1995, 5)}
/>
</div>
)
}Bounded#
Navigation stops at startMonth and endMonth, and disabled blocks days outside the window. useToday() gives a today that is safe to use during server rendering.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client"
import { addDays, startOfMonth } from "date-fns"
import { Calendar } from "@/components/ui/calendar"
import { useToday } from "@/hooks/use-today"
export function CalendarBounded() {
const today = useToday()
const last = today && addDays(today, 45)
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="single"
startMonth={today && startOfMonth(today)}
endMonth={last && startOfMonth(last)}
disabled={
today && last ? [{ before: today }, { after: last }] : undefined
}
footer="Bookable for the next 45 days."
/>
</div>
)
}Controlled month#
Pass month and onMonthChange to drive the visible month. Jumps slide in the direction of travel and the height eases between 5 and 6 week rows.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { useToday } from "@/hooks/use-today"
export function CalendarControlled() {
const today = useToday()
const [month, setMonth] = React.useState<Date>()
const [date, setDate] = React.useState<Date>()
const shown = month ?? today
return (
<div className="flex max-w-full min-w-0 flex-col items-center gap-3">
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="single"
month={shown}
onMonthChange={setMonth}
selected={date}
onSelect={setDate}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const now = new Date()
setMonth(now)
setDate(now)
}}
>
Today
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const base = shown ?? new Date()
setMonth(new Date(base.getFullYear() + 1, base.getMonth()))
}}
>
Next year
</Button>
</div>
</div>
)
}Week numbers#
showWeekNumber adds a week column. ISOWeek uses ISO numbering, starting on Monday. showOutsideDays={false} hides days of other months.
| Mo | Tu | We | Th | Fr | Sa | Su | |
|---|---|---|---|---|---|---|---|
40 | |||||||
41 | |||||||
42 | |||||||
43 | |||||||
44 |
import { Calendar } from "@/components/ui/calendar"
export function CalendarWeekNumbers() {
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar mode="range" showWeekNumber ISOWeek showOutsideDays={false} />
</div>
)
}Fixed today#
Pass today to pin the highlighted day, for tests or another time zone. animate={false} turns off month transitions.
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client"
import * as React from "react"
import type { DateRange } from "react-day-picker"
import { Calendar } from "@/components/ui/calendar"
export function CalendarFixedToday() {
const [range, setRange] = React.useState<DateRange | undefined>({
from: new Date(2026, 1, 9),
to: new Date(2026, 1, 17),
})
return (
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar
mode="range"
today={new Date(2026, 1, 11)}
defaultMonth={new Date(2026, 1)}
selected={range}
onSelect={setRange}
animate={false}
/>
</div>
)
}Inside a sheet#
Inside a sheet, popover or dialog the calendar drops its own background and blends with the surface.
"use client"
import * as React from "react"
import { format } from "date-fns"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
export function CalendarSheet() {
const [date, setDate] = React.useState<Date>()
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
{date ? format(date, "PPP") : "Choose a date"}
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Schedule</SheetTitle>
<SheetDescription>Pick a delivery day.</SheetDescription>
</SheetHeader>
<SheetBody>
<Calendar mode="single" selected={date} onSelect={setDate} />
</SheetBody>
</SheetContent>
</Sheet>
)
}Right to left#
Pass a locale from react-day-picker/locale and dir="rtl". Arrows, navigation and the slide direction all mirror. Inside a dir="rtl" DirectionProvider, the direction is picked up automatically.
| أحد | اثنين | ثلاثاء | أربعاء | خميس | جمعة | سبت |
|---|---|---|---|---|---|---|
"use client"
import { arSA } from "react-day-picker/locale"
import { Calendar } from "@/components/ui/calendar"
export function CalendarRtl() {
return (
<div dir="rtl" className="max-w-full min-w-0">
<div className="w-fit max-w-full overflow-x-auto rounded-xl border">
<Calendar mode="range" locale={arSA} dir="rtl" />
</div>
</div>
)
}Focus a day, then use these keys. Moving past the visible month changes the month.
| Key | Action |
|---|---|
| ←→ | Previous or next day. Reversed in right-to-left layouts. |
| ↑↓ | Same day of the previous or next week. |
| Shift←→ | Previous or next month. |
| Shift↑↓ | Previous or next year. |
| Page UpPage Down | Previous or next month. |
| ShiftPage UpPage Down | Previous or next year. |
| Home | First day of the week. |
| End | Last day of the week. |
| EnterSpace | Selects the focused day. |
- The month is a grid. Each day is a button with a full date label, and selected days set
aria-selected. - Month changes made with the keyboard skip the slide and only fade, so focus never moves under a moving grid.
- With reduced motion, month changes fade and the height change is instant.
- On touch screens the day cells grow to 44px.
Accepts every <DayPicker /> prop. The defaults below differ from DayPicker’s or are added by HextaUI.
| Prop | Type | Default |
|---|---|---|
modeWithout a mode, days are not selectable. | "single" | "multiple" | "range" | – |
selectedMatches the mode. | Date | Date[] | DateRange | – |
onSelect | (selected, triggerDate, modifiers, event) => void | – |
requiredPrevents deselecting the last selection. | boolean | – |
minMinimum days in a range, or selected in multiple mode. | number | – |
maxMaximum days in a range, or selected in multiple mode. | number | – |
excludeDisabledRange mode. | boolean | – |
disabled | Matcher | Matcher[] | – |
monthControlled month. | Date | – |
defaultMonth | Date | – |
onMonthChange | (month: Date) => void | – |
startMonth | Date | – |
endMonth | Date | – |
numberOfMonths | number | 1 |
captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" |
navLayoutHextaUI default. Arrows sit on either side of the caption. | "around" | "after" | "around" |
showOutsideDaysHextaUI default. | boolean | true |
animateMonth slide and height transitions. HextaUI default. | boolean | true |
buttonVariantVariant of the previous and next buttons. | Button variant | "ghost" |
showWeekNumber | boolean | false |
ISOWeek | boolean | false |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | – |
fixedWeeks | boolean | false |
todayDefaults to the client’s today, kept in sync across midnight and hydration. | Date | – |
timeZone | string | – |
locale | Partial<DayPickerLocale> | – |
dir | "ltr" | "rtl" | – |
footer | ReactNode | – |
| Attribute | Description |
|---|---|
data-slot="calendar" | Target the calendar root in CSS. |
--cell-size | Day cell size. 36px, or 44px on touch screens. |
--cell-radius | Corner radius of day cells and buttons. |
data-slot="calendar-day" | Day cells. Carry data-selected, data-disabled, data-outside, data-today, data-hidden and data-focused. |
data-preview | On day cells: start, middle or end of the hovered range preview. |
data-range-middle | On day cells inside a selected range. |
The button inside each day. Pass your own to components={{ DayButton }} and reuse this one to keep the styling.
| Attribute | Description |
|---|---|
data-slot="calendar-day-button" | Target day buttons in CSS. |
data-day | The ISO date, like 2026-10-03. |
data-today | Present on today, outside days excluded. |
data-selected-single | Selected outside a range. |
data-range-start | First day of the range. |
data-range-middle | A day inside the range. |
data-range-end | Last day of the range. |
Returns today as a Date on the client and undefined during server rendering, so bounds built from it never cause a hydration mismatch. It updates at midnight. See the useToday guide.