Skip to content

Date Picker

A field that shows a date and opens a Calendar to choose one. It composes Field, Popover and Calendar. Single dates only; ranges and times are not part of CK-UI yet.

Installation

Add Date Picker to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add date-picker
Adds
DatePicker.tsxCalendar.tsxField.tsxLabel.tsxPopover.tsxThemeScope.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-label@radix-ui/react-popover@radix-ui/react-slotreact-day-pickerreact-iconsif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install react-day-picker react-icons @radix-ui/react-popover @radix-ui/react-slot @radix-ui/react-label

Add the Calendar component

DatePicker imports ./Calendar, so add this file next to it

/components/ui/Calendar.tsx
"use client";

import { DayPicker, type ChevronProps, type Matcher, type PropsBase, type PropsSingle } from "react-day-picker";
import { RiArrowDownSLine, RiArrowLeftSLine, RiArrowRightSLine, RiArrowUpSLine } from "react-icons/ri";
import { cn } from "@/lib/utils";

/*
 * A month calendar for picking one date, built on react-day-picker (which also does the date arithmetic):
 *   role="grid" with labelled day buttons ("Monday, June 1st, 2026, selected"), one tab stop (roving focus),
 *   arrows move by day/week, PageUp/PageDown by month, Shift+PageUp/PageDown by year, Home/End to week start/end.
 *   The month caption is a polite live region, so month changes are announced.
 * `min` / `max` disable dates outside the range and stop navigation past them.
 * Locale: pass a react-day-picker locale (`import { fr } from "react-day-picker/locale"`); the default is en-US.
 */

export type CalendarProps = Omit<PropsBase, "disabled" | "mode" | "required"> &
    Omit<PropsSingle, "mode"> & {
        /** Earliest selectable date. */
        min?: Date;
        /** Latest selectable date. */
        max?: Date;
        /** Dates that cannot be selected (react-day-picker matchers: dates, ranges, `{ dayOfWeek }`, functions). */
        disabled?: Matcher | Matcher[];
    };

function CalendarChevron({ orientation, className }: ChevronProps) {
    const Icon = orientation === "left" ? RiArrowLeftSLine : orientation === "right" ? RiArrowRightSLine : orientation === "up" ? RiArrowUpSLine : RiArrowDownSLine;
    return <Icon aria-hidden="true" className={cn("size-5", className)} />;
}

const toArray = <T,>(value: T | T[] | undefined): T[] => (value === undefined ? [] : Array.isArray(value) ? value : [value]);

const dayCell = [
    "size-9 p-0 text-center",
    "[&[data-outside]>button]:text-muted-foreground",
    "[&[data-today]>button]:font-semibold [&[data-today]>button]:ring-1 [&[data-today]>button]:ring-inset [&[data-today]>button]:ring-foreground/25",
    "[&[data-selected]>button]:bg-primary [&[data-selected]>button]:text-primary-foreground [&[data-selected]>button]:ring-0 [&[data-selected]>button:hover]:bg-primary-hover",
    "[&[data-disabled]>button]:cursor-not-allowed [&[data-disabled]>button]:text-muted-foreground [&[data-disabled]>button]:line-through [&[data-disabled]>button]:opacity-50 [&[data-disabled]>button:hover]:bg-transparent",
].join(" ");

/** Pass `selected` + `onSelect` (controlled) or let it manage its own selection. */
function Root({ className, classNames, components, showOutsideDays = true, min, max, disabled, startMonth, endMonth, ...props }: CalendarProps) {
    const disabledMatchers = [...toArray(disabled), ...(min ? [{ before: min }] : []), ...(max ? [{ after: max }] : [])];

    return (
        <DayPicker
            mode="single"
            showOutsideDays={showOutsideDays}
            startMonth={startMonth ?? min}
            endMonth={endMonth ?? max}
            disabled={disabledMatchers.length ? disabledMatchers : undefined}
            className={cn("relative w-fit p-3", className)}
            classNames={{
                months: "relative flex flex-col gap-4 sm:flex-row",
                month: "flex w-full flex-col gap-3",
                nav: "absolute inset-x-3 top-3 z-[1] flex items-center justify-between",
                button_previous:
                    "inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground focus-ring aria-disabled:pointer-events-none aria-disabled:opacity-40",
                button_next:
                    "inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground focus-ring aria-disabled:pointer-events-none aria-disabled:opacity-40",
                month_caption: "flex h-8 items-center justify-center px-10",
                caption_label: "size-label-sm text-foreground",
                month_grid: "w-full border-collapse",
                weekdays: "",
                weekday: "size-9 pb-1 text-center font-normal size-p-xs text-muted-foreground",
                weeks: "",
                week: "",
                day: dayCell,
                day_button:
                    "inline-flex size-9 items-center justify-center rounded-lg size-p-sm text-foreground transition-colors duration-fast hover:bg-muted focus-ring",
                footer: "mt-3 size-p-xs text-muted-foreground",
                hidden: "invisible",
                outside: "",
                today: "",
                selected: "",
                disabled: "",
                focused: "",
                ...classNames,
            }}
            components={{ Chevron: CalendarChevron, ...components }}
            {...props}
        />
    );
}
Root.displayName = "Calendar.Root";

export { Root };

Add the Popover component

DatePicker imports ./Popover, so add this file next to it

/components/ui/Popover.tsx
"use client";

import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Slottable } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { usePortalContainer } from "./ThemeScope";

const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

/** Open state that works both controlled (`open` + `onOpenChange`) and uncontrolled (`defaultOpen`). */
function useControllableOpen(open: boolean | undefined, defaultOpen: boolean | undefined, onOpenChange: ((open: boolean) => void) | undefined) {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
    const isControlled = open !== undefined;
    const value = isControlled ? open : uncontrolledOpen;

    const latest = React.useRef({ value, isControlled, onOpenChange });
    useIsomorphicLayoutEffect(() => {
        latest.current = { value, isControlled, onOpenChange };
    });

    const setOpen = React.useCallback((next: boolean) => {
        const current = latest.current;
        if (next === current.value) return;
        if (!current.isControlled) setUncontrolledOpen(next);
        current.onOpenChange?.(next);
    }, []);

    return [value, setOpen] as const;
}

/** Calls the consumer handler first; ours runs unless the consumer prevented the default. */
function composeHandlers<E extends { defaultPrevented: boolean }>(theirs: ((event: E) => void) | undefined, ours: ((event: E) => void) | undefined) {
    if (!ours) return theirs;
    return (event: E) => {
        theirs?.(event);
        if (!event.defaultPrevented) ours(event);
    };
}

function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
    if (typeof ref === "function") ref(value);
    else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

// ---------------------------------------------------------------------------------------------
// Sizes
// ---------------------------------------------------------------------------------------------

export type PopoverSize = "sm" | "md" | "lg";
/** @deprecated Use `sm` / `md` / `lg`. */
type LegacyPopoverSize = "small" | "medium" | "large";

const LEGACY_SIZES: Record<LegacyPopoverSize, PopoverSize> = { small: "sm", medium: "md", large: "lg" };

export function resolvePopoverSize(size?: PopoverSize | LegacyPopoverSize | null): PopoverSize {
    if (!size) return "md";
    return size in LEGACY_SIZES ? LEGACY_SIZES[size as LegacyPopoverSize] : (size as PopoverSize);
}

// `md` keeps the original look. The footer bleeds to the panel edges, so its offsets follow the padding.
const sizeStyles: Record<PopoverSize, { content: string; bodyWithFooter: string; footer: string }> = {
    sm: { content: "p-3 size-p-xs", bodyWithFooter: "mb-3", footer: "-mx-3 -mb-3 mt-3 px-3 py-3" },
    md: { content: "p-5 size-p-xs", bodyWithFooter: "mb-5", footer: "-mx-5 -mb-5 mt-5 px-5 py-4" },
    lg: { content: "p-6 size-p-sm", bodyWithFooter: "mb-6", footer: "-mx-6 -mb-6 mt-6 px-6 py-4" },
};

// ---------------------------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------------------------

type OpenReason = "click" | "hover" | "focus";

interface PopoverRootContextValue {
    triggerMode: "click" | "hover";
    contentRef: React.MutableRefObject<HTMLDivElement | null>;
    onTriggerPointerEnter: (event: React.PointerEvent) => void;
    onTriggerPointerLeave: (event: React.PointerEvent) => void;
    onTriggerFocus: () => void;
    onTriggerBlur: (event: React.FocusEvent) => void;
    onTriggerClick: (event: React.MouseEvent) => void;
    onContentPointerEnter: (event: React.PointerEvent) => void;
    onContentPointerLeave: (event: React.PointerEvent) => void;
    onContentFocus: () => void;
    onContentOpenAutoFocus: (event: Event) => void;
    onContentCloseAutoFocus: (event: Event) => void;
}

const PopoverRootContext = React.createContext<PopoverRootContextValue | null>(null);

const PopoverContext = React.createContext<{
    hasFooter: boolean;
    setHasFooter: React.Dispatch<React.SetStateAction<boolean>>;
    size: PopoverSize;
} | null>(null);

// ---------------------------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------------------------

export interface RootProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Root> {
    /**
     * `click` (default): the trigger toggles the popover and focus moves into it.
     * `hover`: opens on mouse hover and on keyboard focus of the trigger without moving focus;
     * clicking (or Enter/Space on) the trigger moves focus into the popover.
     */
    triggerMode?: "click" | "hover";
    /** Delay in ms before opening/closing on mouse hover (hover mode). @default 200 */
    hoverDelay?: number;
}

/** Works controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`) in both trigger modes. */
const Root = ({ children, triggerMode = "click", hoverDelay = 200, open: openProp, defaultOpen, onOpenChange, modal }: RootProps) => {
    const [open, setOpen] = useControllableOpen(openProp, defaultOpen, onOpenChange);

    const openRef = React.useRef(open);
    useIsomorphicLayoutEffect(() => {
        openRef.current = open;
    });

    const contentRef = React.useRef<HTMLDivElement | null>(null);
    const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
    const openReasonRef = React.useRef<OpenReason>("click");
    const focusInsideRef = React.useRef(false);
    const ignoreNextFocusRef = React.useRef(false);

    const clearTimer = React.useCallback(() => {
        if (timerRef.current) clearTimeout(timerRef.current);
        timerRef.current = null;
    }, []);
    React.useEffect(() => clearTimer, [clearTimer]);

    const show = React.useCallback(
        (reason: OpenReason) => {
            clearTimer();
            if (openRef.current) return;
            openReasonRef.current = reason;
            focusInsideRef.current = false;
            setOpen(true);
        },
        [clearTimer, setOpen]
    );

    const hide = React.useCallback(() => {
        clearTimer();
        setOpen(false);
    }, [clearTimer, setOpen]);

    // Changes coming from Radix: trigger click (click mode), Escape, outside interaction, Popover.Close.
    const handleOpenChange = React.useCallback(
        (next: boolean) => {
            if (next) show("click");
            else hide();
        },
        [show, hide]
    );

    const context = React.useMemo<PopoverRootContextValue>(() => {
        const scheduleShow = () => {
            clearTimer();
            if (openRef.current) return;
            timerRef.current = setTimeout(() => show("hover"), hoverDelay);
        };
        const scheduleHide = () => {
            clearTimer();
            timerRef.current = setTimeout(hide, hoverDelay);
        };
        const focusContent = () => {
            const content = contentRef.current;
            if (!content) return;
            const target = content.querySelector<HTMLElement>(TABBABLE) ?? content;
            target.focus();
        };

        return {
            triggerMode,
            contentRef,
            onTriggerPointerEnter: (event) => {
                if (event.pointerType === "mouse") scheduleShow();
            },
            onTriggerPointerLeave: (event) => {
                if (event.pointerType === "mouse") scheduleHide();
            },
            onTriggerFocus: () => {
                if (ignoreNextFocusRef.current) {
                    ignoreNextFocusRef.current = false;
                    return;
                }
                show("focus");
            },
            onTriggerBlur: (event) => {
                const next = event.relatedTarget as Node | null;
                if (next && contentRef.current?.contains(next)) return;
                hide();
            },
            onTriggerClick: (event) => {
                // Replace Radix's toggle: open (focus moves in) or, when already open, move focus into the content.
                event.preventDefault();
                if (openRef.current) focusContent();
                else show("click");
            },
            onContentPointerEnter: (event) => {
                if (event.pointerType === "mouse") clearTimer();
            },
            onContentPointerLeave: (event) => {
                if (event.pointerType === "mouse") scheduleHide();
            },
            onContentFocus: () => {
                focusInsideRef.current = true;
            },
            onContentOpenAutoFocus: (event) => {
                // Opened by hover or keyboard focus: keep focus where it is.
                if (triggerMode === "hover" && openReasonRef.current !== "click") event.preventDefault();
            },
            onContentCloseAutoFocus: (event) => {
                if (triggerMode !== "hover") return;
                if (!focusInsideRef.current) {
                    // Focus never entered the popover: do not move it.
                    event.preventDefault();
                    return;
                }
                // Radix returns focus to the trigger; that focus must not reopen the popover.
                ignoreNextFocusRef.current = true;
                setTimeout(() => {
                    ignoreNextFocusRef.current = false;
                }, 0);
            },
        };
    }, [triggerMode, hoverDelay, clearTimer, show, hide]);

    return (
        <PopoverRootContext.Provider value={context}>
            <PopoverPrimitive.Root open={open} onOpenChange={handleOpenChange} modal={modal}>
                {children}
            </PopoverPrimitive.Root>
        </PopoverRootContext.Provider>
    );
};
Root.displayName = "Popover.Root";

// ---------------------------------------------------------------------------------------------
// Trigger / Anchor
// ---------------------------------------------------------------------------------------------

const Trigger = React.forwardRef<React.ElementRef<typeof PopoverPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>>(
    ({ onPointerEnter, onPointerLeave, onFocus, onBlur, onClick, ...props }, ref) => {
        const root = React.useContext(PopoverRootContext);
        const hover = root?.triggerMode === "hover" ? root : null;

        return (
            <PopoverPrimitive.Trigger
                ref={ref}
                {...props}
                onPointerEnter={composeHandlers(onPointerEnter, hover?.onTriggerPointerEnter)}
                onPointerLeave={composeHandlers(onPointerLeave, hover?.onTriggerPointerLeave)}
                onFocus={composeHandlers(onFocus, hover?.onTriggerFocus)}
                onBlur={composeHandlers(onBlur, hover?.onTriggerBlur)}
                onClick={composeHandlers(onClick, hover?.onTriggerClick)}
            />
        );
    }
);
Trigger.displayName = "Popover.Trigger";

const Anchor = PopoverPrimitive.Anchor;

// ---------------------------------------------------------------------------------------------
// Content
// ---------------------------------------------------------------------------------------------

type PopoverPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | "left" | "right";
type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";

const PLACEMENT_BY_POSITION: Record<PopoverPosition, { side: Side; align: Align }> = {
    "top-left": { side: "top", align: "start" },
    "top-center": { side: "top", align: "center" },
    "top-right": { side: "top", align: "end" },
    "bottom-left": { side: "bottom", align: "start" },
    "bottom-center": { side: "bottom", align: "center" },
    "bottom-right": { side: "bottom", align: "end" },
    left: { side: "left", align: "center" },
    right: { side: "right", align: "center" },
};

const ANIMATION_CLASSES: Record<"fade" | "scale" | "slide", string> = {
    fade: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
    scale: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
    slide: [
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
        "data-[side=bottom]:data-[state=open]:slide-in-from-top-2 data-[side=bottom]:data-[state=closed]:slide-out-to-top-2",
        "data-[side=left]:data-[state=open]:slide-in-from-right-2 data-[side=left]:data-[state=closed]:slide-out-to-right-2",
        "data-[side=right]:data-[state=open]:slide-in-from-left-2 data-[side=right]:data-[state=closed]:slide-out-to-left-2",
        "data-[side=top]:data-[state=open]:slide-in-from-bottom-2 data-[side=top]:data-[state=closed]:slide-out-to-bottom-2",
    ].join(" "),
};

export interface ContentProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
    showArrow?: boolean;
    /** Removes the default surface, padding and typography. */
    unstyled?: boolean;
    animation?: "fade" | "scale" | "slide";
    /** Padding and text size. `small` / `medium` / `large` are deprecated aliases. @default "md" */
    size?: PopoverSize | LegacyPopoverSize;
    /** Placement shorthand. Explicit `side` / `align` props take precedence. @default "top-center" */
    position?: PopoverPosition;
}

/** The popover panel (`role="dialog"`); `className` goes on it. */
const Content = React.forwardRef<React.ElementRef<typeof PopoverPrimitive.Content>, ContentProps>(
    (
        {
            children,
            className,
            side: sideProp,
            align: alignProp,
            sideOffset = 12,
            collisionPadding = 12,
            arrowPadding = 12,
            showArrow = true,
            unstyled,
            animation = "scale",
            size,
            position = "top-center",
            onOpenAutoFocus,
            onCloseAutoFocus,
            onPointerEnter,
            onPointerLeave,
            onFocus,
            ...props
        },
        forwardedRef
    ) => {
        const root = React.useContext(PopoverRootContext);
        const [hasFooter, setHasFooter] = React.useState(false);
        const resolvedSize = resolvePopoverSize(size);
        const placement = PLACEMENT_BY_POSITION[position];
        const hover = root?.triggerMode === "hover" ? root : null;

        const ref = React.useCallback(
            (node: HTMLDivElement | null) => {
                if (root) root.contentRef.current = node;
                setRef(forwardedRef, node);
            },
            [root, forwardedRef]
        );

        const contextValue = React.useMemo(() => ({ hasFooter, setHasFooter, size: resolvedSize }), [hasFooter, resolvedSize]);
        const container = usePortalContainer();

        return (
            <PopoverContext.Provider value={contextValue}>
                <PopoverPrimitive.Portal container={container}>
                    <PopoverPrimitive.Content
                        ref={ref}
                        side={sideProp ?? placement.side}
                        align={alignProp ?? placement.align}
                        sideOffset={sideOffset}
                        collisionPadding={collisionPadding}
                        arrowPadding={arrowPadding}
                        onOpenAutoFocus={composeHandlers(onOpenAutoFocus, root?.onContentOpenAutoFocus)}
                        onCloseAutoFocus={composeHandlers(onCloseAutoFocus, root?.onContentCloseAutoFocus)}
                        onPointerEnter={composeHandlers(onPointerEnter, hover?.onContentPointerEnter)}
                        onPointerLeave={composeHandlers(onPointerLeave, hover?.onContentPointerLeave)}
                        onFocus={composeHandlers(onFocus, root?.onContentFocus)}
                        className={cn(
                            "z-popover",
                            !unstyled && ["w-max rounded-2xl bg-background text-foreground shadow-medium ring-1 ring-border", sizeStyles[resolvedSize].content],
                            ANIMATION_CLASSES[animation],
                            // The footer bleeds to the bottom edge.
                            hasFooter && !unstyled && "pb-0",
                            className
                        )}
                        {...props}
                    >
                        <div className={cn(hasFooter && !unstyled && sizeStyles[resolvedSize].bodyWithFooter)}>
                            <Slottable>{children}</Slottable>
                        </div>

                        {showArrow && (
                            <PopoverPrimitive.Arrow asChild>
                                <div className="size-[11px] -translate-y-[calc(50%)] -rotate-45 rounded-bl-[3px] border border-border bg-background [clip-path:polygon(0_100%,0_0,100%_100%)]"></div>
                            </PopoverPrimitive.Arrow>
                        )}
                    </PopoverPrimitive.Content>
                </PopoverPrimitive.Portal>
            </PopoverContext.Provider>
        );
    }
);
Content.displayName = "Popover.Content";

// ---------------------------------------------------------------------------------------------
// Close / Footer
// ---------------------------------------------------------------------------------------------

const Close = React.forwardRef<
    React.ElementRef<typeof PopoverPrimitive.Close>,
    React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Close> & {
        unstyled?: boolean;
    }
>(({ className, unstyled, ...props }, forwardedRef) => (
    <PopoverPrimitive.Close ref={forwardedRef} className={cn(!unstyled && "absolute right-4 top-4 rounded-lg focus-ring", className)} {...props} />
));
Close.displayName = "Popover.Close";

type FooterProps = React.HTMLAttributes<HTMLDivElement>;

const Footer = React.forwardRef<HTMLDivElement, FooterProps>(({ className, children, ...props }, ref) => {
    const context = React.useContext(PopoverContext);
    const setHasFooter = context?.setHasFooter;
    const size = context?.size ?? "md";

    useIsomorphicLayoutEffect(() => {
        if (!setHasFooter) return;
        setHasFooter(true);
        return () => setHasFooter(false);
    }, [setHasFooter]);

    return (
        <div ref={ref} className={cn("flex items-center justify-between gap-3 rounded-b-2xl border-t border-border bg-muted", sizeStyles[size].footer, className)} {...props}>
            {children}
        </div>
    );
});
Footer.displayName = "Popover.Footer";

export { Root, Anchor, Trigger, Content, Close, Footer };

Add the ThemeScope component

DatePicker imports ./ThemeScope, so add this file next to it

/components/ui/ThemeScope.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * A local light/dark boundary. Everything inside renders in `theme`, whatever the site theme is: the semantic tokens
 * (`bg-background`, `text-foreground`, …) and every `dark:` utility resolve to the nearest boundary, not to <html>.
 * Overlays that portal out of the tree (Select, Popover, Tooltip, Dropdown, Modal, AlertDialog, Drawer, Toast) render
 * into a layer that carries the same theme, so a menu opened inside a dark scope is dark too.
 *
 * It never touches <html>, the site's theme state or its storage. Without `theme` it renders no theme class and
 * follows the surrounding theme. Nest at most one scope below the site theme (see `darkMode` in tailwind.config.ts).
 */

export type ScopeTheme = "light" | "dark";

const PortalContainerContext = React.createContext<HTMLElement | null>(null);

/** Where overlays portal to: the nearest themed ThemeScope's layer, or null for the default (document.body). */
export function usePortalContainer() {
    return React.useContext(PortalContainerContext);
}

// A themed layer at the end of <body> for portalled overlays. Only created while the scope sets a theme, so a scope
// that follows the site leaves the document exactly as it was.
function usePortalLayer(theme: ScopeTheme | undefined) {
    const [layer, setLayer] = React.useState<HTMLElement | null>(null);
    const themed = theme !== undefined;

    React.useEffect(() => {
        if (!themed) return;
        const element = document.createElement("div");
        element.setAttribute("data-theme-scope-layer", "");
        document.body.appendChild(element);
        setLayer(element);
        return () => {
            element.remove();
            setLayer(null);
        };
    }, [themed]);

    React.useEffect(() => {
        if (!layer || !theme) return;
        // text-foreground: portalled text that sets no colour of its own resolves against the layer's tokens.
        layer.className = `${theme} text-foreground`;
    }, [layer, theme]);

    return layer;
}

export interface RootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Theme for this subtree. Leave undefined to follow the surrounding (site) theme. */
    theme?: ScopeTheme;
}

const Root = React.forwardRef<HTMLDivElement, RootProps>(({ theme, className, children, ...props }, ref) => {
    const layer = usePortalLayer(theme);
    // A scope that follows its surroundings keeps the surrounding scope's layer.
    const outerLayer = usePortalContainer();

    return (
        <div ref={ref} data-theme-scope={theme} className={cn(theme && [theme, "text-foreground"], className)} {...props}>
            <PortalContainerContext.Provider value={theme ? layer : outerLayer}>{children}</PortalContainerContext.Provider>
        </div>
    );
});
Root.displayName = "ThemeScope.Root";

export { Root };

Add the Field component

DatePicker imports ./Field, so add this file next to it

/components/ui/Field.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";

/*
 * Field wires a label, description and message to one form control:
 *   Field.Label        → <label for={controlId}>
 *   Field.Description  → referenced by aria-describedby
 *   Field.Message      → referenced by aria-describedby (error text when invalid)
 *   invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
 * Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
 * useFieldControlProps. It is plain React state: it works with native forms and form libraries.
 */

type FieldContextValue = {
    controlId: string;
    labelId: string;
    descriptionId: string;
    messageId: string;
    invalid: boolean;
    required: boolean;
    disabled: boolean;
    hasDescription: boolean;
    hasMessage: boolean;
    setHasDescription: (value: boolean) => void;
    setHasMessage: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
    return React.useContext(FieldContext);
}

/**
 * Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
 * this lets each Field.Root reflect that state (label styling, `disabled` on the control).
 */
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);

export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Id for the control; generated when omitted. */
    id?: string;
    invalid?: boolean;
    required?: boolean;
    disabled?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
    const fieldset = React.useContext(FieldsetContext);
    const disabled = disabledProp || Boolean(fieldset?.disabled);
    const generatedId = React.useId();
    const controlId = id ?? `field-${generatedId}`;
    const [hasDescription, setHasDescription] = React.useState(false);
    const [hasMessage, setHasMessage] = React.useState(false);

    const value = React.useMemo<FieldContextValue>(
        () => ({
            controlId,
            labelId: `${controlId}-label`,
            descriptionId: `${controlId}-description`,
            messageId: `${controlId}-message`,
            invalid,
            required,
            disabled,
            hasDescription,
            hasMessage,
            setHasDescription,
            setHasMessage,
        }),
        [controlId, invalid, required, disabled, hasDescription, hasMessage]
    );

    return (
        <FieldContext.Provider value={value}>
            <div
                ref={ref}
                className={cn("flex flex-col gap-1.5", className)}
                data-invalid={invalid || undefined}
                data-disabled={disabled || undefined}
                {...props}
            >
                {children}
            </div>
        </FieldContext.Provider>
    );
});
Root.displayName = "Field.Root";

type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;

const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
    const field = useField();

    return (
        <Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
            {children}
            {field?.required && <Label.Asterisk />}
        </Label.Root>
    );
});
FieldLabel.displayName = "Field.Label";

const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
    const field = useField();
    const setHasDescription = field?.setHasDescription;

    React.useEffect(() => {
        if (!setHasDescription) return;
        setHasDescription(true);
        return () => setHasDescription(false);
    }, [setHasDescription]);

    return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";

export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
    /** Only render while the field is invalid (default true). */
    showWhenValid?: boolean;
}

const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
    const field = useField();
    const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
    const setHasMessage = field?.setHasMessage;

    React.useEffect(() => {
        if (!setHasMessage || !visible) return;
        setHasMessage(true);
        return () => setHasMessage(false);
    }, [setHasMessage, visible]);

    if (!visible) return null;

    return (
        <p
            ref={ref}
            id={field?.messageId}
            aria-live="polite"
            className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
            {...props}
        >
            {children}
        </p>
    );
});
Message.displayName = "Field.Message";

type ControlAttributes = {
    id?: string;
    "aria-describedby"?: string;
    "aria-invalid"?: React.AriaAttributes["aria-invalid"];
    "aria-required"?: React.AriaAttributes["aria-required"];
    required?: boolean;
    disabled?: boolean;
};

/**
 * Merges Field context into a control's props. Explicit props win; describedby ids are combined.
 */
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
    const field = useField();
    const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");

    const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
        .filter(Boolean)
        .join(" ");

    return {
        ...props,
        id: props.id ?? field?.controlId,
        "aria-describedby": describedBy || undefined,
        "aria-invalid": invalid || undefined,
        required: props.required ?? (field?.required || undefined),
        disabled: props.disabled ?? (field?.disabled || undefined),
    };
}

/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
    const controlProps = useFieldControlProps(props);
    return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";

export { Root, FieldLabel as Label, Description, Message, Control };

Add the Label component

DatePicker imports ./Label, so add this file next to it

/components/ui/Label.tsx
"use client";

import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";

export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
    /** Dims the label. The control itself must also be disabled. */
    disabled?: boolean;
}

const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
    return (
        <LabelPrimitive.Root
            ref={ref}
            data-disabled={disabled || undefined}
            className={cn(
                "group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
                "data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
                className
            )}
            {...props}
        />
    );
});
Root.displayName = "Label.Root";

// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
    <span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
        {children || "*"}
    </span>
));
Asterisk.displayName = "Label.Asterisk";

const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
    <span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";

export { Root, Asterisk, Sub };

Create or update your DatePicker component

Copy this code to create your DatePicker component

/components/ui/DatePicker.tsx
"use client";

import * as React from "react";
import type { DayPickerLocale, Matcher } from "react-day-picker";
import { RiCalendarLine, RiCloseLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
import * as Calendar from "./Calendar";
import { useFieldControlProps } from "./Field";
import * as Popover from "./Popover";

/*
 * A trigger that shows the chosen date and opens a Calendar in a popover. Like Combobox, the trigger is a
 * role="combobox" with aria-haspopup="dialog": its name comes from the label and its value is the shown date.
 * Formatting uses Intl.DateTimeFormat with `lang` (default: the calendar locale's code, else "en-US"),
 * so server and browser render the same text.
 * Native forms: with `name`, the date is submitted as YYYY-MM-DD; `required` blocks submission while empty.
 */

export type DatePickerSize = "sm" | "md" | "lg";

const triggerSizes: Record<DatePickerSize, string> = {
    sm: "h-8 px-2.5",
    md: "h-9 px-3",
    lg: "h-10 px-3",
};

/** Local calendar date as YYYY-MM-DD (no time zone shift). */
export function toISODate(date: Date) {
    const pad = (value: number) => String(value).padStart(2, "0");
    return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

export interface DatePickerRootProps {
    /** Selected date (controlled). `null` means no date. */
    value?: Date | null;
    defaultValue?: Date | null;
    onValueChange?: (date: Date | null) => void;
    open?: boolean;
    defaultOpen?: boolean;
    onOpenChange?: (open: boolean) => void;
    placeholder?: string;
    min?: Date;
    max?: Date;
    /** Extra dates that cannot be picked (react-day-picker matchers). */
    disabledDates?: Matcher | Matcher[];
    /** Calendar locale (labels, week start). Import from "react-day-picker/locale". */
    locale?: Partial<DayPickerLocale>;
    /** BCP 47 tag for the trigger text. Defaults to the locale's code, then "en-US". */
    lang?: string;
    /** Intl.DateTimeFormat options for the trigger text. */
    formatOptions?: Intl.DateTimeFormatOptions;
    weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
    clearable?: boolean;
    clearLabel?: string;
    disabled?: boolean;
    invalid?: boolean;
    required?: boolean;
    name?: string;
    form?: string;
    size?: DatePickerSize;
    id?: string;
    "aria-label"?: string;
    "aria-labelledby"?: string;
    "aria-describedby"?: string;
    className?: string;
}

function useControllable<T>(value: T | undefined, defaultValue: T, onChange: ((value: T) => void) | undefined) {
    const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
    const isControlled = value !== undefined;
    const setValue = React.useCallback(
        (next: T) => {
            if (!isControlled) setUncontrolled(next);
            onChange?.(next);
        },
        [isControlled, onChange]
    );
    return [isControlled ? (value as T) : uncontrolled, setValue] as const;
}

const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { dateStyle: "medium" };

const Root = React.forwardRef<HTMLButtonElement, DatePickerRootProps>(
    (
        {
            value: valueProp,
            defaultValue = null,
            onValueChange,
            open: openProp,
            defaultOpen = false,
            onOpenChange,
            placeholder = "Pick a date",
            min,
            max,
            disabledDates,
            locale,
            lang,
            formatOptions = DEFAULT_FORMAT,
            weekStartsOn,
            clearable = false,
            clearLabel = "Clear date",
            disabled,
            invalid,
            required,
            name,
            form,
            size = "md",
            id,
            "aria-label": ariaLabel,
            "aria-labelledby": ariaLabelledBy,
            "aria-describedby": ariaDescribedBy,
            className,
        },
        forwardedRef
    ) => {
        const [value, setValue] = useControllable<Date | null>(valueProp, defaultValue, onValueChange);
        const [open, setOpen] = useControllable<boolean>(openProp, defaultOpen, onOpenChange);
        const [missingRequired, setMissingRequired] = React.useState(false);
        const triggerRef = React.useRef<HTMLButtonElement | null>(null);
        const contentRef = React.useRef<HTMLDivElement | null>(null);
        const contentId = `${React.useId()}-calendar-dialog`;

        const controlProps = useFieldControlProps({ id, "aria-describedby": ariaDescribedBy, disabled, required }, { invalid: invalid || (missingRequired && !value) });
        const isInvalid = Boolean(controlProps["aria-invalid"]);
        const isDisabled = Boolean(controlProps.disabled);
        const isRequired = Boolean(controlProps.required);

        const formatted = React.useMemo(() => {
            if (!value) return null;
            try {
                return new Intl.DateTimeFormat(lang ?? locale?.code ?? "en-US", formatOptions).format(value);
            } catch {
                return toISODate(value);
            }
        }, [value, lang, locale, formatOptions]);


        const setTriggerRef = React.useCallback(
            (node: HTMLButtonElement | null) => {
                triggerRef.current = node;
                if (typeof forwardedRef === "function") forwardedRef(node);
                else if (forwardedRef) forwardedRef.current = node;
            },
            [forwardedRef]
        );

        const handleOpenChange = (next: boolean) => {
            if (isDisabled && next) return;
            setOpen(next);
        };

        const showClear = clearable && value !== null && !isDisabled;

        return (
            <div className={cn("relative w-full", className)}>
                <Popover.Root open={open} onOpenChange={handleOpenChange}>
                    <Popover.Trigger asChild>
                        <button
                            ref={setTriggerRef}
                            type="button"
                            role="combobox"
                            aria-haspopup="dialog"
                            aria-expanded={open}
                            aria-controls={open ? contentId : undefined}
                            id={controlProps.id}
                            aria-label={ariaLabel}
                            aria-labelledby={ariaLabelledBy}
                            aria-describedby={controlProps["aria-describedby"]}
                            aria-invalid={isInvalid || undefined}
                            aria-required={isRequired || undefined}
                            disabled={isDisabled}
                            data-placeholder={value ? undefined : ""}
                            className={cn(
                                "flex w-full items-center gap-2 rounded-lg border bg-transparent text-left size-p-sm text-foreground transition duration-base ease-standard field-focus",
                                triggerSizes[size],
                                showClear && "pr-10",
                                isInvalid
                                    ? "border-destructive outline-destructive/25 hover:bg-destructive-subtle"
                                    : "border-input outline-accent hover:bg-muted data-[state=open]:border-foreground focus:border-foreground",
                                "data-[placeholder]:text-muted-foreground",
                                "disabled:cursor-not-allowed disabled:border-transparent disabled:bg-muted disabled:text-muted-foreground"
                            )}
                        >
                            <RiCalendarLine aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
                            <span className="truncate">
                                {formatted ?? placeholder}
                            </span>
                        </button>
                    </Popover.Trigger>
                    <Popover.Content
                        ref={contentRef}
                        id={contentId}
                        unstyled
                        showArrow={false}
                        position="bottom-left"
                        sideOffset={6}
                        animation="slide"
                        collisionPadding={8}
                        onOpenAutoFocus={(event) => {
                            // Start on the selected date (or today): the grid's single tab stop.
                            const day = contentRef.current?.querySelector<HTMLElement>("td button[tabindex='0']");
                            if (day) {
                                event.preventDefault();
                                day.focus();
                            }
                        }}
                        className="rounded-2xl border border-border bg-surface text-surface-foreground shadow-medium"
                    >
                        <Calendar.Root
                            selected={value ?? undefined}
                            defaultMonth={value ?? undefined}
                            onSelect={(date) => {
                                setValue(date ?? null);
                                setMissingRequired(false);
                                handleOpenChange(false);
                            }}
                            min={min}
                            max={max}
                            disabled={disabledDates}
                            locale={locale}
                            weekStartsOn={weekStartsOn}
                        />
                    </Popover.Content>
                </Popover.Root>

                {showClear && (
                    <button
                        type="button"
                        aria-label={clearLabel}
                        onClick={() => {
                            setValue(null);
                            triggerRef.current?.focus();
                        }}
                        className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground focus-ring"
                    >
                        <RiCloseLine aria-hidden="true" className="size-4" />
                    </button>
                )}

                {(name || isRequired) && (
                    <input
                        tabIndex={-1}
                        aria-hidden="true"
                        name={name}
                        form={form}
                        value={value ? toISODate(value) : ""}
                        required={isRequired}
                        disabled={isDisabled}
                        onChange={() => {}}
                        onInvalid={(event) => {
                            event.preventDefault();
                            setMissingRequired(true);
                            triggerRef.current?.focus();
                        }}
                        className="pointer-events-none absolute bottom-0 left-0 size-px opacity-0"
                    />
                )}
            </div>
        );
    }
);
Root.displayName = "DatePicker.Root";

export { Root };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Birthday

Controlled value, a max of today, and custom Intl formatting for the trigger text.

Future dates are disabled. Use PageUp/PageDown for months and Shift+PageUp/PageDown for years.

Min / Max

min and max disable dates outside the range and stop month navigation there. disabledDates takes Calendar matchers (here weekends).

Within the next 30 days, Monday to Friday.

Validation and Locale

A required date in a form with a French calendar (locale from react-day-picker/locale); the trigger text follows the locale too. With name the form receives YYYY-MM-DD.

Accessibility

  • The trigger is a role="combobox" button with aria-haspopup="dialog": its name comes from Field.Label and its value is the formatted date (or the placeholder).
  • Opening moves focus to the selected date (or today) in the calendar grid. Arrows move by day/week, PageUp/PageDown by month, Shift+PageUp/PageDown by year. Enter picks; Escape closes and returns focus to the trigger.
  • Day buttons have full labels ("Tuesday, June 16th, 2026"); month changes are announced.
  • Field wiring: description and message in aria-describedby, aria-invalid, aria-required. Required and empty blocks native form submission.
  • Text is formatted with Intl.DateTimeFormat using lang, else the locale's code, else en-US, so the server and the browser render the same text.

API Reference

Import with import * as DatePicker from "@/components/ui/DatePicker". Requires the Calendar component and react-day-picker.

DatePicker.Root

ref points at the trigger button.

PropTypeDefaultDescription
value / defaultValue"Date | null""null"

Selected date.

onValueChange"(date: Date | null) => void""undefined"

Called on pick and clear.

min / max"Date""undefined"

Earliest / latest date.

disabledDates"Matcher | Matcher[]""undefined"

Other dates that cannot be picked.

locale"react-day-picker locale""en-US"

Calendar language and week start.

lang"string""locale code"

BCP 47 tag for the trigger text.

formatOptions"Intl.DateTimeFormatOptions""{ dateStyle: "medium" }"

Trigger text format.

weekStartsOn"0 – 6""locale"

First day of the week.

placeholder"string""Pick a date"

Trigger text when empty.

clearable / clearLabel"boolean / string""false / Clear date"

Clear button.

disabled / invalid / required"boolean""from Field"

State; also read from Field.Root.

name / form"string""undefined"

Submits YYYY-MM-DD.

open / defaultOpen / onOpenChange"boolean""false"

Popup state.

size
"sm""md""lg"
"md"

Trigger height.

className"string""undefined"

Classes for the wrapper.

toISODate(date)

Formats a Date as YYYY-MM-DD in local time (no time-zone shift).

PropTypeDefaultDescription
date"Date""-"

The date to format.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.

Application