Date picker Adapters
Using the slotsmith Date picker with shadcn/ui, MUI, Chakra, Ant Design and Radix Themes.
An adapter can also be set once for the whole app instead of on every DatePicker: <SlotsmithProvider components={{ datePicker: shadcnComponents }}>. See how the provider's slots resolve.
shadcn/ui
This adapter renders DatePicker with shadcn/ui parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports these shadcn/ui components; add them with npx shadcn@latest add button input-group native-select popover. Or install it in one step from slotsmith's shadcn/ui registry: add "@slotsmith": "https://slotsmith.dev/r/{name}.json" to the registries in your components.json, then run npx shadcn@latest add @slotsmith/date-picker. It writes components/slotsmith/date-picker.tsx, with these parts and a <DatePicker /> that already renders with them, and adds the shadcn/ui components it needs. npx shadcn@latest add @slotsmith/provider applies every adapter to the whole app through one SlotsmithProvider.
import { useState } from "react";
import { addDays, DatePicker, type DateRange } from "slotsmith/date-picker";
import { useToday } from "../../shared/today";
import { shadcnComponents } from "./shadcn";
export default function ShadcnDatePicker() {
const today = useToday();
const [stay, setStay] = useState<DateRange | null>(() => ({ from: addDays(today, 2), to: addDays(today, 5) }));
return (
<DatePicker
mode="range"
value={stay}
onChange={setStay}
presets={[
{ label: "Next 7 days", value: { from: today, to: addDays(today, 6) } },
{ label: "Tomorrow", value: addDays(today, 1) },
]}
components={shadcnComponents}
placeholder="Pick your dates"
aria-label="Stay"
/>
);
}import { useSyncExternalStore } from "react";
import { today, type ISODate } from "slotsmith/date-picker";
/** The date a prerendered page is built with, so its HTML is the same on every build. */
export const PRERENDER_TODAY: ISODate = "2026-03-16";
// Today does not change while the page is open, so there is nothing to subscribe to.
const subscribe = () => () => {};
/**
* Today as `YYYY-MM-DD`, safe to prerender: the fixed date on the server and
* while hydrating, so the markup matches, then the visitor's real date.
* An app that renders only in the browser can call `today()` directly.
*/
export function useToday(): ISODate {
return useSyncExternalStore(subscribe, today, () => PRERENDER_TODAY);
}The demo sits inside shadcn/ui's provider, as any app built on it already does.
shadcn.tsx
/**
* DatePicker for shadcn/ui
*
* A `components` map that renders the slotsmith date picker with shadcn/ui
* primitives, written for shadcn/ui on Tailwind CSS v4. Copy the file, keep
* the parts you want, and pass the map as `components={shadcnComponents}`;
* every slot left out keeps its fallback.
*
* The `@/components/ui/*` and `@/lib/utils` imports are the app's own
* shadcn/ui files.
*/
import { useMemo, type RefObject } from "react";
import {
useDatePickerContext,
type DatePickerComponents,
} from "slotsmith/date-picker";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
/**
* shadcn/ui date picker parts
*
* What the `components` map looks like against shadcn/ui. Element parts are
* plain Tailwind on the DOM node the component already renders; only the
* widget parts need a body.
*
* The range band is built from stacked `data-*` variants rather than from
* class order: `[data-in-range][data-range-start]` outranks `[data-in-range]`
* on specificity, so which corners are round never depends on how Tailwind
* happens to sort its output.
*/
const Chevron = ({ flip }: { flip?: boolean }) => (
<svg
className={cn("size-4", flip && "rotate-180")}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
);
/**
* The caption's two dropdowns: shadcn's `NativeSelect`, as shadcn's own
* calendar uses native selects for its month and year. The browser draws the
* open list itself, and `color-scheme` makes it dark in dark mode.
*/
const captionSelect =
"h-7 border-transparent py-0 ps-2 pe-7 text-sm font-medium shadow-none scheme-light dark:scheme-dark";
/** The open list takes the popover's colours: on Windows the browser draws it from the options' own. */
const captionOption = "bg-popover text-popover-foreground";
/**
* Calendar layout
*
* The calendar's own structure, which no slot renders: the header row, the
* seven columns of the weekday and week rows, and the centred cells. Laid
* out here too, so the picker holds together without slotsmith's stylesheet.
* Tailwind reads `_` in a selector as a space, so the class names' own
* underscores are escaped, in `String.raw` so the backslash stays.
*/
const calendarLayout = cn(
String.raw`[&_.sdp\_\_header]:flex [&_.sdp\_\_header]:items-center [&_.sdp\_\_header]:justify-between [&_.sdp\_\_header]:gap-1 [&_.sdp\_\_header]:pb-2`,
String.raw`[&_.sdp\_\_weekdays]:grid [&_.sdp\_\_weekdays]:grid-cols-7 [&_.sdp\_\_week]:grid [&_.sdp\_\_week]:grid-cols-7`,
String.raw`[&_.sdp\_\_columnheader]:grid [&_.sdp\_\_columnheader]:min-w-0 [&_.sdp\_\_columnheader]:place-items-center`,
String.raw`[&_.sdp\_\_cell]:grid [&_.sdp\_\_cell]:min-w-0 [&_.sdp\_\_cell]:place-items-center`,
);
export const shadcnComponents: Partial<DatePickerComponents> = {
Root: ({ className, ...props }) => (
<div
data-slot="date-picker"
className={cn("relative w-full max-w-72", className)}
{...props}
/>
),
/**
* shadcn's `InputGroup`: the field frame, with its border, ring and
* destructive state. With the text field it holds the `InputGroupInput`
* and the buttons; as the single button trigger it is the combobox itself,
* a frame rather than a `Button` because it holds the clear button and a
* button cannot hold another.
*/
Trigger: ({ className, ...props }) => (
<InputGroup
className={cn(
"min-h-9 cursor-pointer gap-2 bg-background px-3 text-sm",
"data-open:border-ring data-open:ring-[3px] data-open:ring-ring/50",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
/** The text-field trigger: the field and its buttons sit right against the frame. */
"data-[trigger=input]:cursor-text data-[trigger=input]:gap-0 data-[trigger=input]:px-0",
"data-[trigger=input]:data-disabled:cursor-not-allowed data-[trigger=input]:data-disabled:opacity-50",
"data-invalid:border-destructive data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
),
Value: ({ text, placeholder, empty }) => (
<span className={cn("flex-1 truncate", empty && "text-muted-foreground")}>
{empty ? placeholder : text}
</span>
),
/** The group's `InputGroupInput`: the frame draws the field around it. */
Input: ({ className, ...props }) => (
<InputGroupInput
className={cn("h-auto min-w-0 px-3", className)}
{...props}
/>
),
/** An icon `InputGroupButton` around the calendar glyph, in the inline-end addon. */
Toggle: ({ className, type = "button", ...props }) => (
<InputGroupAddon
align="inline-end"
className="p-0 pe-1.5 has-[>button]:me-0"
>
<InputGroupButton
type={type}
size="icon-xs"
className={cn("size-7", className)}
{...props}
/>
</InputGroupAddon>
),
Icon: ({ open }) => (
<svg
className={cn(
"size-4 shrink-0 opacity-60 transition-opacity",
open && "text-primary opacity-100",
)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M8 3v4M16 3v4M3 11h18" />
</svg>
),
/** An icon `InputGroupButton`; the click stops here so it does not reach the trigger. */
Clear: ({ onClick, ...aria }) => (
<InputGroupAddon align="inline-end" className="p-0 has-[>button]:me-0">
<InputGroupButton
size="icon-xs"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
<svg
viewBox="0 0 24 24"
className="size-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</InputGroupButton>
</InputGroupAddon>
),
/**
* shadcn's `Popover`, anchored to the trigger: it portals the calendar,
* places and animates it as every shadcn popover does. Focus stays with
* the picker, which moves it into the grid itself. The content keeps every
* prop the part is given, its `id` included, which is how the picker knows
* the portaled calendar is still part of it; the built-in position style is
* left out.
*/
Popup: function ShadcnPopup({ className, style: _style, ...props }) {
const { position } = useDatePickerContext();
const anchor = useMemo(
() => ({ current: position.anchor }),
[position.anchor],
);
if (!position.anchor) return null;
return (
<Popover open>
<PopoverAnchor virtualRef={anchor as RefObject<HTMLElement>} />
<PopoverContent
align="start"
side={position.placement === "top-start" ? "top" : "bottom"}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
// Its own stacking context, so the range band behind each day stays above the surface.
className={cn("isolate w-72 p-2", calendarLayout, className)}
{...props}
/>
</Popover>
);
},
Calendar: ({ className, ...props }) => (
<div className={cn("grid gap-0.5", className)} {...props} />
),
Caption: ({
monthIndex,
year,
months,
years,
onMonthChange,
onYearChange,
labels,
}) => (
<span className="flex items-center gap-1">
<NativeSelect
size="sm"
className={captionSelect}
aria-label={labels.month}
value={monthIndex}
onChange={(event) => onMonthChange(Number(event.target.value))}
>
{months.map((name, index) => (
<NativeSelectOption
key={name}
value={index}
className={captionOption}
>
{name}
</NativeSelectOption>
))}
</NativeSelect>
<NativeSelect
size="sm"
className={captionSelect}
aria-label={labels.year}
value={year}
onChange={(event) => onYearChange(Number(event.target.value))}
>
{years.map((value) => (
<NativeSelectOption
key={value}
value={value}
className={captionOption}
>
{value}
</NativeSelectOption>
))}
</NativeSelect>
</span>
),
/** A ghost icon `Button` with the chevron, mirrored for the next month. */
Nav: ({ direction, onClick, disabled, ...aria }) => (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClick}
disabled={disabled}
className="size-7 disabled:opacity-35"
{...aria}
>
<Chevron flip={direction === "next"} />
</Button>
),
/**
* One column header. Clipped rather than wrapped: the column is one cell
* wide, and locales whose short weekday names are full words would
* otherwise make the row two lines tall.
*/
Weekday: ({ label }) => (
<span className="block truncate py-1 text-center text-[0.7rem] font-medium text-muted-foreground">
{label}
</span>
),
/**
* One cell of the grid.
*
* A fixed `size-8` square, centred in its column, so the button is exactly
* as wide as it is tall rather than stretching to the column. Blocked days
* are dimmed through `data-[disabled]`: they stay focusable, so the
* `disabled:` variant never applies.
*
* The band is a `before:` layer at a negative z-index: it reaches half the
* gutter past each inline edge so neighbouring days meet across the gap,
* and it sits behind both the picked chip and the label.
*/
Day: ({ className, type = "button", ...props }) => (
<Button
type={type}
variant="ghost"
size="icon"
className={cn(
"relative size-8 justify-self-center rounded-md text-[0.8125rem] font-normal",
"data-disabled:cursor-not-allowed data-disabled:opacity-30",
"data-outside:text-muted-foreground",
"data-today:inset-ring-1 data-today:inset-ring-primary/60",
"data-in-range:before:absolute data-in-range:before:inset-y-0 data-in-range:before:content-['']",
"data-in-range:before:inset-x-[calc(var(--sdp-gap,0.25rem)/-2-0.5px)]",
"data-in-range:before:z-[-1] data-in-range:before:bg-primary/15",
"data-in-preview:bg-primary/10",
"data-in-range:data-range-start:before:start-[0]",
"data-in-range:data-range-start:before:rounded-s-md",
"data-in-range:data-range-end:before:end-[0]",
"data-in-range:data-range-end:before:rounded-e-md",
"data-selected:bg-primary data-selected:font-semibold data-selected:text-primary-foreground",
"data-selected:hover:bg-primary data-selected:hover:text-primary-foreground",
className,
)}
{...props}
/>
),
DayContent: ({ day }) => <span>{day.day}</span>,
/** Outline `Button` chips for the presets, and a ghost `Button` for today. */
Footer: ({ presets, onPreset, onToday, todayLabel }) => (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-border pt-2">
{presets.map((preset) => (
<Button
key={preset.label}
type="button"
variant="outline"
size="sm"
onClick={() => onPreset(preset.value)}
className="h-6 rounded-full px-2 text-xs font-normal"
>
{preset.label}
</Button>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToday}
className="ms-auto h-6 rounded-full px-2 text-xs font-normal text-muted-foreground"
>
{todayLabel}
</Button>
</div>
),
};MUI
This adapter renders DatePicker with MUI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @mui/material.
import { useState } from "react";
import { addDays, DatePicker, type DateRange } from "slotsmith/date-picker";
import { useToday } from "../../shared/today";
import { muiComponents } from "./mui";
export default function MuiDatePicker() {
const today = useToday();
const [stay, setStay] = useState<DateRange | null>(() => ({ from: addDays(today, 2), to: addDays(today, 5) }));
return (
<DatePicker
mode="range"
value={stay}
onChange={setStay}
presets={[
{ label: "Next 7 days", value: { from: today, to: addDays(today, 6) } },
{ label: "Tomorrow", value: addDays(today, 1) },
]}
components={muiComponents}
placeholder="Pick your dates"
aria-label="Stay"
/>
);
}import { useSyncExternalStore } from "react";
import { today, type ISODate } from "slotsmith/date-picker";
/** The date a prerendered page is built with, so its HTML is the same on every build. */
export const PRERENDER_TODAY: ISODate = "2026-03-16";
// Today does not change while the page is open, so there is nothing to subscribe to.
const subscribe = () => () => {};
/**
* Today as `YYYY-MM-DD`, safe to prerender: the fixed date on the server and
* while hydrating, so the markup matches, then the visitor's real date.
* An app that renders only in the browser can call `today()` directly.
*/
export function useToday(): ISODate {
return useSyncExternalStore(subscribe, today, () => PRERENDER_TODAY);
}The demo sits inside MUI's provider, as any app built on it already does.
mui.tsx
/**
* DatePicker for MUI
*
* A `components` map that renders the slotsmith date picker with MUI
* primitives, tested against MUI v7. Copy the file, keep the parts you want,
* and pass the map as `components={muiComponents}`; every slot left out keeps
* its fallback.
*/
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import IconButton from "@mui/material/IconButton";
import InputBase from "@mui/material/InputBase";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import Select from "@mui/material/Select";
import type { SxProps, Theme } from "@mui/material/styles";
import Typography from "@mui/material/Typography";
import { useDatePickerContext, type DatePickerComponents } from "slotsmith/date-picker";
/**
* MUI date picker parts
*
* Built from MUI's primitives — `Paper`, `IconButton`, `Select`, `Typography`,
* `Box`, `Chip` — not from MUI's own date picker, which is a competing engine
* rather than a set of parts and ships in a separate package. The result
* wears Material and follows the theme, dark mode included.
*
* This is the reason the element parts `Omit` `color` from their DOM props:
* MUI reuses that attribute name for its own semantic palette prop, so a slot
* that forwarded the DOM `color` would collide with `<IconButton color>`.
*/
/** The day cell, in pixels. Matches `--sdp-cell`, so the square fills its column. */
const DAY_SIZE = 32;
/** The chip's corner, shared with the two ends of the band so they line up. */
const DAY_RADIUS = 8;
const ChevronIcon = ({ flip }: { flip: boolean }) => (
<Box
component="svg"
viewBox="0 0 24 24"
aria-hidden="true"
sx={{
width: 18,
height: 18,
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
transform: flip ? "rotate(180deg)" : undefined,
}}
>
<path d="m15 18-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</Box>
);
/**
* Caption select
*
* The month and year dropdowns share their whole look.
*
* These are `native`, not `MenuItem` lists, for two reasons that both come
* from MUI rendering its menu as a `Modal`: a portalled menu is outside the
* picker's root, so choosing a month would count as an outside click and
* close the calendar; and with the portal disabled the modal marks the page
* behind it `aria-hidden` while focus is still inside it, which the browser
* reports.
*/
const captionSx: SxProps<Theme> = {
"& .MuiNativeSelect-select": { py: 0.25, typography: "body2", fontWeight: 600 },
"&::before, &::after": { display: "none" },
};
/**
* Calendar style
*
* The surface, in its own stacking context so the range band behind each
* day stays above it, and the calendar's own structure, which no slot
* renders: the header row, the seven columns of the weekday and week rows,
* and the centred cells. Laid out here, so the picker holds together
* without slotsmith's stylesheet.
*/
const calendarSx: SxProps<Theme> = {
width: 288,
p: 1,
isolation: "isolate",
"& .sdp__header": { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 0.5, pb: 1 },
"& :is(.sdp__weekdays, .sdp__week)": { display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" },
"& :is(.sdp__columnheader, .sdp__cell)": { display: "grid", placeItems: "center", minWidth: 0 },
};
export const muiComponents: Partial<DatePickerComponents> = {
Root: ({ className, ...props }) => (
<Box className={className} sx={{ position: "relative", width: 1, maxWidth: 288 }} {...props} />
),
/**
* An outlined-input shell, so it sits beside MUI's TextFields without
* looking pasted in. An invalid value takes the theme's `error` colour and
* MUI's `Mui-error` state class, as a `TextField` with `error` does.
*/
Trigger: ({ className, ...props }) => (
<Box
className={[className, props["aria-invalid"] || (props as Record<string, unknown>)["data-invalid"] ? "Mui-error" : undefined].filter(Boolean).join(" ") || undefined}
sx={{
cursor: "pointer",
outline: 0,
width: 1,
minHeight: 40,
display: "flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 0.75,
borderRadius: 1,
border: 1,
borderColor: "divider",
typography: "body2",
color: "text.primary",
textAlign: "start",
"&:hover": { borderColor: "text.primary" },
"&[data-open], &:focus-visible": {
borderColor: "primary.main",
boxShadow: (theme) => `0 0 0 1px ${theme.palette.primary.main}`,
},
"&[data-trigger='input']": { cursor: "text", pe: 0.5 },
"&[data-trigger='input']:focus-within": {
borderColor: "primary.main",
boxShadow: (theme) => `0 0 0 1px ${theme.palette.primary.main}`,
},
"&[aria-disabled='true'], &[data-trigger='input'][data-disabled]": { opacity: 0.5, cursor: "not-allowed" },
"&.Mui-error": { borderColor: "error.main" },
"&.Mui-error[data-open], &.Mui-error:focus-visible, &.Mui-error[data-trigger='input']:focus-within": {
boxShadow: (theme) => `0 0 0 1px ${theme.palette.error.main}`,
},
}}
{...props}
/>
),
/** MUI's bare `InputBase`: the trigger frame holds the border, the field only the text. */
Input: ({ ref, value, onChange, onKeyDown, onBlur, onFocus, onClick, disabled, placeholder, className, ...inputProps }) => (
<InputBase
inputRef={ref}
className={className}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
onFocus={onFocus}
onClick={onClick}
disabled={disabled}
placeholder={placeholder}
inputProps={inputProps}
sx={{ flex: 1, minWidth: 0, typography: "body2", "& input": { p: 0 } }}
/>
),
/** A small `IconButton` around the calendar glyph. */
Toggle: ({ children, ...props }) => (
<IconButton size="small" {...props}>
{children}
</IconButton>
),
Value: ({ text, placeholder, empty }) => (
<Typography
variant="body2"
noWrap
sx={{ flex: 1, minWidth: 0 }}
color={empty ? "text.secondary" : "text.primary"}
>
{empty ? placeholder : text}
</Typography>
),
Icon: ({ open }) => (
<Box
component="svg"
viewBox="0 0 24 24"
aria-hidden="true"
sx={{
width: 20,
height: 20,
flex: "none",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
color: open ? "primary.main" : "action.active",
}}
>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M8 3v4M16 3v4M3 11h18" strokeLinecap="round" />
</Box>
),
Clear: ({ onClick, ...aria }) => (
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
<Box
component="svg"
viewBox="0 0 24 24"
aria-hidden="true"
sx={{ width: 16, height: 16, fill: "none", stroke: "currentColor", strokeWidth: 2.5 }}
>
<path d="M18 6 6 18M6 6l12 12" strokeLinecap="round" />
</Box>
</IconButton>
),
/**
* MUI's `Popper`: it portals the calendar, places it against the trigger
* (flipping when there is no room) and stacks it with the theme's z-index.
* The `Paper` keeps every prop the part is given, its `id` included, which
* is how the picker knows the portaled calendar is still part of it; the
* built-in position style is left out.
*/
Popup: function MuiPopup({ className, style: _style, ...props }) {
const { position } = useDatePickerContext();
if (!position.anchor) return null;
return (
<Popper
open
anchorEl={position.anchor}
placement={position.placement === "top-start" ? "top-start" : "bottom-start"}
modifiers={[{ name: "offset", options: { offset: [0, 4] } }]}
sx={{ zIndex: "modal" }}
>
{/* Its own stacking context, so the range band behind each day stays above the surface. */}
<Paper className={className} elevation={8} sx={calendarSx} {...props} />
</Popper>
);
},
Calendar: ({ className, ...props }) => (
<Box className={className} sx={{ display: "grid", rowGap: "2px" }} {...props} />
),
Caption: ({ monthIndex, year, months, years, onMonthChange, onYearChange, labels }) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Select
native
variant="standard"
inputProps={{ "aria-label": labels.month }}
value={monthIndex}
onChange={(event) => onMonthChange(Number(event.target.value))}
sx={captionSx}
>
{months.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</Select>
<Select
native
variant="standard"
inputProps={{ "aria-label": labels.year }}
value={year}
onChange={(event) => onYearChange(Number(event.target.value))}
sx={captionSx}
>
{years.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</Box>
),
Nav: ({ direction, onClick, disabled, ...aria }) => (
<IconButton size="small" onClick={onClick} disabled={disabled} {...aria}>
<ChevronIcon flip={direction === "next"} />
</IconButton>
),
/**
* One column header.
*
* `noWrap`, because the column is one cell wide and locales whose short
* weekday names are full words would otherwise make the row two lines tall.
*/
Weekday: ({ label }) => (
<Typography
variant="caption"
align="center"
noWrap
color="text.secondary"
sx={{ display: "block", py: 0.5, fontWeight: 600 }}
>
{label}
</Typography>
),
/**
* One cell of the grid.
*
* An `IconButton`, which is square by construction, pinned to the cell size
* so it neither stretches to the column nor carries the icon padding.
*
* Blocked days are styled from `data-disabled`, not `.Mui-disabled`: the
* component keeps them focusable, so it never sets `disabled` on them.
*
* The band is a `::before` at a negative z-index: it reaches half the gutter
* past each inline edge so neighbouring days meet across the gap, and it
* sits behind both the picked chip and the label. `overflow: visible` is
* asserted because that bleed is outside the button's own box.
*/
Day: ({ className, ...props }) => (
<IconButton
className={className}
sx={{
position: "relative",
justifySelf: "center",
width: DAY_SIZE,
height: DAY_SIZE,
p: 0,
overflow: "visible",
borderRadius: `${DAY_RADIUS}px`,
typography: "body2",
fontSize: "0.8125rem",
color: "text.primary",
"&:hover": { bgcolor: "action.hover" },
"&[data-disabled]": { opacity: 0.3, cursor: "not-allowed" },
"&[data-outside]": { color: "text.disabled" },
"&[data-today]": { boxShadow: (theme) => `inset 0 0 0 1px ${theme.palette.primary.main}` },
"&[data-in-range]::before, &[data-in-preview]::before": {
content: '""',
position: "absolute",
zIndex: -1,
insetBlock: 0,
insetInline: "calc(var(--sdp-gap, 0.25rem) / -2 - 0.5px)",
bgcolor: (theme) => `color-mix(in oklab, ${theme.palette.primary.main}, transparent 85%)`,
},
"&[data-in-range][data-range-start]::before": {
insetInlineStart: 0,
borderStartStartRadius: DAY_RADIUS,
borderEndStartRadius: DAY_RADIUS,
},
"&[data-in-range][data-range-end]::before": {
insetInlineEnd: 0,
borderStartEndRadius: DAY_RADIUS,
borderEndEndRadius: DAY_RADIUS,
},
"&[data-selected]": { bgcolor: "primary.main", color: "primary.contrastText", fontWeight: 600 },
"&[data-selected]:hover": { bgcolor: "primary.dark" },
}}
{...props}
/>
),
DayContent: ({ day }) => <span>{day.day}</span>,
Footer: ({ presets, onPreset, onToday, todayLabel }) => (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 0.5,
mt: 1,
pt: 1,
borderTop: 1,
borderColor: "divider",
}}
>
{presets.map((preset) => (
<Chip
key={preset.label}
size="small"
variant="outlined"
label={preset.label}
onClick={() => onPreset(preset.value)}
/>
))}
<Chip
size="small"
variant="outlined"
label={todayLabel}
onClick={onToday}
sx={{ marginInlineStart: "auto", borderColor: "transparent", color: "text.secondary" }}
/>
</Box>
),
};Chakra UI
This adapter renders DatePicker with Chakra UI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @chakra-ui/react.
import { useState } from "react";
import { addDays, DatePicker, type DateRange } from "slotsmith/date-picker";
import { useToday } from "../../shared/today";
import { chakraComponents } from "./chakra";
export default function ChakraDatePicker() {
const today = useToday();
const [stay, setStay] = useState<DateRange | null>(() => ({ from: addDays(today, 2), to: addDays(today, 5) }));
return (
<DatePicker
mode="range"
value={stay}
onChange={setStay}
presets={[
{ label: "Next 7 days", value: { from: today, to: addDays(today, 6) } },
{ label: "Tomorrow", value: addDays(today, 1) },
]}
components={chakraComponents}
placeholder="Pick your dates"
aria-label="Stay"
/>
);
}import { useSyncExternalStore } from "react";
import { today, type ISODate } from "slotsmith/date-picker";
/** The date a prerendered page is built with, so its HTML is the same on every build. */
export const PRERENDER_TODAY: ISODate = "2026-03-16";
// Today does not change while the page is open, so there is nothing to subscribe to.
const subscribe = () => () => {};
/**
* Today as `YYYY-MM-DD`, safe to prerender: the fixed date on the server and
* while hydrating, so the markup matches, then the visitor's real date.
* An app that renders only in the browser can call `today()` directly.
*/
export function useToday(): ISODate {
return useSyncExternalStore(subscribe, today, () => PRERENDER_TODAY);
}The demo sits inside Chakra UI's provider, as any app built on it already does.
chakra.tsx
/**
* DatePicker for Chakra UI
*
* A `components` map that renders the slotsmith date picker with Chakra UI
* primitives, tested against Chakra UI v3. Copy the file, keep the parts you
* want, and pass the map as `components={chakraComponents}`; every slot left
* out keeps its fallback.
*/
import { Box, Button, chakra, IconButton, NativeSelect, Popover, Portal, Text } from "@chakra-ui/react";
import type { ReactNode } from "react";
import { useDatePickerContext, type DatePickerComponents } from "slotsmith/date-picker";
/**
* Chakra date picker parts
*
* The `components` map against Chakra UI v3 primitives — `Box`, `Button`,
* `IconButton`, `NativeSelect`, `Text` and the `chakra` factory — written
* against Chakra's semantic tokens, so it follows the theme the way the
* table's Chakra parts do.
*
* `NativeSelect` rather than Chakra's `Select` collection on purpose: the
* collection renders its list in a portal, and the picker closes on any
* pointer-down outside its own root.
*/
/** The day cell, on Chakra's spacing scale. `8` is 2rem, which matches `--sdp-cell`. */
const DAY_SIZE = "8";
/**
* Glyph
*
* A styled `<svg>` from Chakra's factory rather than `Icon`, which treats its
* child as the element to render and so swallows a multi-path drawing.
*/
const Glyph = chakra("svg");
const Chevron = ({ flip }: { flip: boolean }) => (
<Glyph
width="4"
height="4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transform={flip ? "rotate(180deg)" : undefined}
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
/**
* Caption select
*
* The month and year dropdowns are the same control with different options.
*
* @param label - Accessible name for the field.
* @param value - The option currently shown.
* @param onValueChange - Receives the new option as a number.
* @param children - The `<option>` list.
* @returns A plain Chakra `NativeSelect`.
*/
const CaptionSelect = ({
label,
value,
onValueChange,
children,
}: {
label: string;
value: number;
onValueChange: (value: number) => void;
children: ReactNode;
}) => (
<NativeSelect.Root size="xs" width="auto" variant="plain">
<NativeSelect.Field
aria-label={label}
value={value}
fontWeight="semibold"
onChange={(event) => onValueChange(Number(event.currentTarget.value))}
>
{children}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
);
/**
* Calendar layout
*
* The calendar's own structure, which no slot renders: the header row, the
* seven columns of the weekday and week rows, and the centred cells. Laid
* out here too, so the picker holds together without slotsmith's stylesheet.
*/
const calendarCss = {
"& .sdp__header": { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "1", pb: "2" },
"& :is(.sdp__weekdays, .sdp__week)": { display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))" },
"& :is(.sdp__columnheader, .sdp__cell)": { display: "grid", placeItems: "center", minWidth: "0" },
} as const;
export const chakraComponents: Partial<DatePickerComponents> = {
/** `colorPalette` is set here for the trigger, and again on the popover, which renders in a portal outside it. */
Root: ({ className, ...props }) => (
<Box className={className} colorPalette="blue" position="relative" width="full" maxWidth="18rem" {...props} />
),
Trigger: ({ className, ...props }) => (
<Box
className={className}
display="flex"
alignItems="center"
gap="2"
width="full"
minHeight="10"
px="3"
py="1.5"
rounded="md"
borderWidth="1px"
bg="bg.panel"
color="fg"
textStyle="sm"
cursor="pointer"
outline="none"
css={{
"&:hover": { borderColor: "border.emphasized" },
"&[data-open], &:focus-visible": { borderColor: "colorPalette.solid" },
"&[aria-disabled='true'], &[data-trigger='input'][data-disabled]": { opacity: 0.5, cursor: "not-allowed" },
"&[data-trigger='input']": { cursor: "text", paddingInlineEnd: "1.5" },
"&[data-trigger='input']:focus-within": { borderColor: "colorPalette.solid" },
/** Chakra's own invalid condition and token, as its `Input` uses them. */
_invalid: { borderColor: "border.error" },
"&[data-invalid]": { borderColor: "border.error" },
}}
{...props}
/>
),
/** A bare `chakra.input`: the trigger frame holds the border. */
Input: (props) => (
<chakra.input
flex="1"
minWidth="0"
bg="transparent"
border="0"
outline="none"
textStyle="sm"
color="fg"
css={{ "&::placeholder": { color: "fg.muted" }, "&:disabled": { cursor: "not-allowed" } }}
{...props}
/>
),
/** A ghost `IconButton` around the calendar glyph. */
Toggle: ({ children, ...props }) => (
<IconButton size="2xs" variant="ghost" {...props}>
{children}
</IconButton>
),
Value: ({ text, placeholder, empty }) => (
<Text as="span" flex="1" minWidth="0" truncate textStyle="sm" color={empty ? "fg.muted" : "fg"}>
{empty ? placeholder : text}
</Text>
),
Icon: ({ open }) => (
<Glyph
width="4"
height="4"
flex="none"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
color={open ? "colorPalette.solid" : "fg.muted"}
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M8 3v4M16 3v4M3 11h18" />
</Glyph>
),
Clear: ({ onClick, ...aria }) => (
<IconButton
size="2xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
<Glyph
width="3"
height="3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</Glyph>
</IconButton>
),
/**
* Chakra's `Popover`: it portals the calendar, places it against the trigger
* and draws Chakra's popover surface. The picker owns the open state, the
* focus and closing, so the popover's own are turned off. The content keeps
* every prop the part is given, its `id` included, which is how the picker
* knows the portaled calendar is still part of it; the built-in position
* style is left out.
*/
Popup: function ChakraPopup({ className, style: _style, ...props }) {
const { position } = useDatePickerContext();
const anchor = position.anchor;
if (!anchor) return null;
return (
<Popover.Root
open
// The popover finds its content by this id, and so does the picker.
ids={{ content: props.id }}
modal={false}
autoFocus={false}
closeOnEscape={false}
closeOnInteractOutside={false}
positioning={{
placement: position.placement === "top-start" ? "top-start" : "bottom-start",
gutter: 4,
getAnchorRect: () => anchor.getBoundingClientRect(),
}}
>
<Portal>
<Popover.Positioner>
{/* Its own stacking context, so the range band behind each day stays above the surface. */}
<Popover.Content className={className} colorPalette="blue" width="18rem" p="2" isolation="isolate" css={calendarCss} {...props} />
</Popover.Positioner>
</Portal>
</Popover.Root>
);
},
Calendar: ({ className, ...props }) => <Box className={className} display="grid" rowGap="0.5" {...props} />,
Caption: ({ monthIndex, year, months, years, onMonthChange, onYearChange, labels }) => (
<Box display="flex" alignItems="center" gap="1">
<CaptionSelect label={labels.month} value={monthIndex} onValueChange={onMonthChange}>
{months.map((name, index) => (
<option key={name} value={index}>
{name}
</option>
))}
</CaptionSelect>
<CaptionSelect label={labels.year} value={year} onValueChange={onYearChange}>
{years.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</CaptionSelect>
</Box>
),
Nav: ({ direction, onClick, disabled, ...aria }) => (
<IconButton size="xs" variant="ghost" disabled={disabled} onClick={onClick} {...aria}>
<Chevron flip={direction === "next"} />
</IconButton>
),
/**
* One column header.
*
* Clipped rather than wrapped: the column is one cell wide, and locales
* whose short weekday names are full words would make the row two lines
* tall.
*/
Weekday: ({ label }) => (
<Text
as="span"
display="block"
truncate
py="1"
textAlign="center"
textStyle="xs"
fontWeight="semibold"
color="fg.muted"
>
{label}
</Text>
),
/**
* One cell of the grid.
*
* An `IconButton` at a fixed `boxSize`, so the button is exactly as wide as
* it is tall rather than stretching to the column. Blocked days are dimmed
* from `data-disabled`: they stay focusable, so `:disabled` never matches.
*
* The band is a `_before` layer at a negative z-index: it reaches half the
* gutter past each inline edge so neighbouring days meet across the gap,
* and it sits behind both the picked chip and the label.
*/
Day: ({ className, ...props }) => (
<IconButton
className={className}
variant="ghost"
boxSize={DAY_SIZE}
minWidth={DAY_SIZE}
justifySelf="center"
position="relative"
overflow="visible"
p="0"
rounded="md"
bg="transparent"
color="fg"
textStyle="sm"
fontWeight="normal"
css={{
"&[data-disabled]": { opacity: 0.3, cursor: "not-allowed" },
"&[data-outside]": { color: "fg.muted" },
"&[data-today]": { boxShadow: "inset 0 0 0 1px var(--chakra-colors-color-palette-solid)" },
"&[data-in-range], &[data-in-preview]": {
_before: {
content: '""',
position: "absolute",
zIndex: -1,
insetBlock: "0",
insetInline: "calc(var(--sdp-gap, 0.25rem) / -2 - 0.5px)",
bg: "colorPalette.subtle",
},
},
"&[data-in-range][data-range-start]": {
_before: { insetInlineStart: "0", borderStartStartRadius: "md", borderEndStartRadius: "md" },
},
"&[data-in-range][data-range-end]": {
_before: { insetInlineEnd: "0", borderStartEndRadius: "md", borderEndEndRadius: "md" },
},
"&[data-selected], &[data-selected]:hover": {
bg: "colorPalette.solid",
color: "colorPalette.contrast",
fontWeight: "semibold",
},
}}
{...props}
/>
),
DayContent: ({ day }) => <span>{day.day}</span>,
Footer: ({ presets, onPreset, onToday, todayLabel }) => (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
gap="1"
mt="2"
pt="2"
borderTopWidth="1px"
borderColor="border"
>
{presets.map((preset) => (
<Button
key={preset.label}
size="xs"
variant="outline"
rounded="full"
onClick={() => onPreset(preset.value)}
>
{preset.label}
</Button>
))}
<Button size="xs" variant="ghost" color="fg.muted" marginInlineStart="auto" onClick={onToday}>
{todayLabel}
</Button>
</Box>
),
};Ant Design
This adapter renders DatePicker with Ant Design parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @ant-design/icons antd.
import { useState } from "react";
import { addDays, DatePicker, type DateRange } from "slotsmith/date-picker";
import { useToday } from "../../shared/today";
import { antdComponents } from "./antd";
export default function AntdDatePicker() {
const today = useToday();
const [stay, setStay] = useState<DateRange | null>(() => ({ from: addDays(today, 2), to: addDays(today, 5) }));
return (
<DatePicker
mode="range"
value={stay}
onChange={setStay}
presets={[
{ label: "Next 7 days", value: { from: today, to: addDays(today, 6) } },
{ label: "Tomorrow", value: addDays(today, 1) },
]}
components={antdComponents}
placeholder="Pick your dates"
aria-label="Stay"
/>
);
}import { useSyncExternalStore } from "react";
import { today, type ISODate } from "slotsmith/date-picker";
/** The date a prerendered page is built with, so its HTML is the same on every build. */
export const PRERENDER_TODAY: ISODate = "2026-03-16";
// Today does not change while the page is open, so there is nothing to subscribe to.
const subscribe = () => () => {};
/**
* Today as `YYYY-MM-DD`, safe to prerender: the fixed date on the server and
* while hydrating, so the markup matches, then the visitor's real date.
* An app that renders only in the browser can call `today()` directly.
*/
export function useToday(): ISODate {
return useSyncExternalStore(subscribe, today, () => PRERENDER_TODAY);
}The demo sits inside Ant Design's provider, as any app built on it already does.
antd.tsx
/**
* DatePicker for Ant Design
*
* A `components` map that renders the slotsmith date picker with Ant Design
* primitives, tested against Ant Design v6. Copy the file, keep the parts you
* want, and pass the map as `components={antdComponents}`; every slot left out
* keeps its fallback.
*/
import { CalendarOutlined, CloseCircleFilled, LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Button, ConfigProvider, Flex, Input, Popover, Select, Typography, theme } from "antd";
import { useContext, useId, useState } from "react";
import { createPortal } from "react-dom";
import type { GlobalToken } from "antd";
import { useDatePickerContext, type DatePickerComponents } from "slotsmith/date-picker";
/**
* Ant Design date picker parts
*
* Built from Ant Design v6 primitives — `Button`, `Flex`, `Typography` and
* Ant's icons — not from Ant's own `DatePicker`, which is a competing engine
* rather than a set of parts. The surfaces take the look of Ant's pickers
* from the theme's tokens; their state arrives as `data-*` props, so each part
* reads its own and picks the matching token.
*
* This is also why the element parts `Omit` `color` from their DOM props:
* Ant's `Button` has a `color` prop of its own, a preset palette, which a
* forwarded DOM `color` would collide with.
*/
/** The day cell, in pixels. Matches `--sdp-cell`, so the square fills its column. */
const DAY_SIZE = 32;
/**
* Has flag
*
* @param props - A part's props.
* @param name - A `data-*` attribute.
* @returns Whether the engine set it.
*/
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;
/**
* Ant Design components
*
* The slot map an Ant Design v6 project would pass as `components`. Every
* part reads the theme's tokens, so a `ConfigProvider` theme, dark included,
* reaches the calendar without extra wiring.
*/
/**
* Calendar sheet
*
* The calendar's own structure, which no slot renders: the header row, the
* seven columns of the weekday and week rows, and the centred cells. Ant's
* parts take inline styles, which cannot reach those, so a sheet scoped to
* the popup lays them out, and the picker holds together without
* slotsmith's stylesheet. It carries the nonce from `ConfigProvider`'s
* `csp`, as Ant's own styles do.
*
* @param scope - The popup's scope class.
* @param token - The theme's tokens.
* @returns The sheet.
*/
function calendarSheet(scope: string, token: GlobalToken): string {
const s = `.${scope}`;
return `${s} .sdp__header { display: flex; align-items: center; justify-content: space-between; gap: ${token.paddingXXS}px; }
${s} :is(.sdp__weekdays, .sdp__week) { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
${s} :is(.sdp__columnheader, .sdp__cell) { display: grid; place-items: center; min-width: 0; }`;
}
export const antdComponents: Partial<DatePickerComponents> = {
/** The box the popup is placed against, at the width of Ant's pickers. */
Root: ({ style, ...props }) => <div style={{ position: "relative", width: "100%", maxWidth: 288, ...style }} {...props} />,
/**
* The outlined field of Ant's pickers: the primary border and focus ring
* while open, and the error border and ring of `status="error"` when the
* value is invalid.
*/
Trigger: function AntTrigger({ style, onFocus, onBlur, ...props }) {
const { token } = theme.useToken();
/** Focus anywhere in the frame, so the text field gets the ring Ant Design's `Input` shows. */
const [focused, setFocused] = useState(false);
const typing = (props as Record<string, unknown>)["data-trigger"] === "input";
const open = has(props, "data-open") || (typing && focused);
const disabled = has(props, "data-disabled");
const invalid = has(props, "data-invalid");
const edge = invalid ? token.colorError : open ? token.colorPrimary : token.colorBorder;
const ring = invalid ? token.colorErrorOutline : token.controlOutline;
return (
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: token.paddingXS,
width: "100%",
minHeight: token.controlHeight,
paddingBlock: token.paddingXXS,
paddingInline: token.paddingSM - 1,
color: token.colorText,
fontSize: token.fontSize,
textAlign: "start",
outline: 0,
background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
border: `${token.lineWidth}px ${token.lineType} ${edge}`,
borderRadius: token.borderRadius,
boxShadow: open ? `0 0 0 ${token.controlOutlineWidth}px ${ring}` : undefined,
cursor: disabled ? "not-allowed" : typing ? "text" : "pointer",
transition: `all ${token.motionDurationMid}`,
...style,
}}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
{...props}
/>
);
},
/** A borderless `Input`: the trigger frame holds the border and the ring. */
Input: ({ ref, style, ...props }) => (
<Input
variant="borderless"
ref={(input) => {
const node = input?.input ?? null;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
style={{ flex: 1, minWidth: 0, padding: 0, ...style }}
{...props}
/>
),
/** A text `Button` with the calendar glyph. Its `type` is the button's look, so the HTML type goes in `htmlType`. */
Toggle: ({ type: _type, children, ...props }) => <Button type="text" htmlType="button" size="small" icon={children} {...props} />,
/** The trigger text on one ellipsised line, in the placeholder colour while empty. */
Value: function AntValue({ text, placeholder, empty }) {
const { token } = theme.useToken();
return (
<Typography.Text
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
color: empty ? token.colorTextPlaceholder : undefined,
}}
>
{empty ? placeholder : text}
</Typography.Text>
);
},
/** The calendar glyph, in the primary colour while the popup is open. */
Icon: function AntIcon({ open }) {
const { token } = theme.useToken();
return (
<span aria-hidden="true" style={{ display: "inline-flex", color: open ? token.colorPrimary : token.colorTextQuaternary }}>
<CalendarOutlined />
</span>
);
},
/** A text `Button` with the filled close glyph; the click stops here so it does not also open the popup. */
Clear: ({ onClick, ...aria }) => (
<Button
type="text"
size="small"
icon={<CloseCircleFilled />}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
/>
),
/**
* Ant Design's `Popover`: it portals the calendar, places it against the
* trigger (flipping when there is no room) and draws Ant's popover surface.
* An Ant popover is placed against its child, so the child is an empty box
* laid over the trigger, rendered into it. The calendar keeps every prop
* the part is given, its `id` included, which is how the picker knows the
* portaled calendar is still part of it; the built-in position style is
* left out.
*/
Popup: function AntPopup({ style: _style, children, ...props }) {
const { position } = useDatePickerContext();
const { token } = theme.useToken();
const { csp } = useContext(ConfigProvider.ConfigContext);
const scope = `ss-antd-calendar-${useId().replace(/[^\w-]/g, "")}`;
if (!position.anchor) return null;
return createPortal(
<Popover
open
arrow={false}
trigger={[]}
placement={position.placement === "top-start" ? "topLeft" : "bottomLeft"}
content={
// Its own stacking context, so the range band behind each day stays above the surface.
<div className={scope} style={{ width: 288, isolation: "isolate" }} {...props}>
<style nonce={csp?.nonce}>{calendarSheet(scope, token)}</style>
{children}
</div>
}
>
<span aria-hidden="true" style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
</Popover>,
position.anchor,
);
},
/** The grid of weeks, with a small gap between rows. */
Calendar: ({ style, ...props }) => <div style={{ display: "grid", rowGap: 2, ...style }} {...props} />,
/**
* The month and year as Ant's borderless `Select`, like the header of
* Ant's own picker. Its menu renders beside it, inside the calendar's popup,
* so choosing a month is a press inside the picker and does not close it.
*/
Caption: function AntCaption({ monthIndex, year, months, years, onMonthChange, onYearChange, labels }) {
const { token } = theme.useToken();
const inside = (node: HTMLElement) => node.parentElement ?? document.body;
return (
<Flex align="center" gap={token.paddingXXS}>
<Select
size="small"
variant="borderless"
aria-label={labels.month}
value={monthIndex}
onChange={onMonthChange}
options={months.map((name, index) => ({ label: name, value: index }))}
popupMatchSelectWidth={false}
virtual={false}
getPopupContainer={inside}
style={{ fontWeight: token.fontWeightStrong }}
/>
<Select
size="small"
variant="borderless"
aria-label={labels.year}
value={year}
onChange={onYearChange}
options={years.map((value) => ({ label: String(value), value }))}
popupMatchSelectWidth={false}
virtual={false}
getPopupContainer={inside}
style={{ fontWeight: token.fontWeightStrong }}
/>
</Flex>
);
},
/** `direction` already accounts for right to left, so the arrow follows it as is. */
Nav: ({ direction, onClick, disabled, ...aria }) => (
<Button
type="text"
size="small"
icon={direction === "next" ? <RightOutlined /> : <LeftOutlined />}
onClick={onClick}
disabled={disabled}
{...aria}
/>
),
/**
* One column header.
*
* No wrapping, because the column is one cell wide and locales whose short
* weekday names are full words would otherwise make the row two lines tall.
*/
Weekday: function AntWeekday({ label }) {
const { token } = theme.useToken();
return (
<Typography.Text
type="secondary"
style={{ display: "block", paddingBlock: token.paddingXXS, fontSize: token.fontSizeSM, textAlign: "center", whiteSpace: "nowrap" }}
>
{label}
</Typography.Text>
);
},
/**
* One cell of the grid.
*
* A `text` `Button`, pinned to the cell size, so the hover is Ant's own;
* the picked fill is painted from the theme's primary tokens. The variant
* never changes: Ant wraps every other variant in an extra element, so
* switching to `primary` as the day is picked would replace the button and
* drop focus to `<body>`. The HTML `type` moves to `htmlType`, since Ant's
* `type` is the variant.
*
* Blocked days are styled from `data-disabled`, not Ant's `disabled`: the
* component keeps them focusable, so it never sets `disabled` on them.
*
* The range band is a span behind the button, at a negative z-index: it
* reaches half the gutter past each inline edge so neighbouring days meet
* across the gap, and stops square at the button's edge on the two ends,
* behind the picked day's rounded corners.
*/
Day: function AntDay({ type, style, children, ...props }) {
const { token } = theme.useToken();
const selected = has(props, "data-selected");
const band = has(props, "data-in-range") || has(props, "data-in-preview");
const start = has(props, "data-in-range") && has(props, "data-range-start");
const end = has(props, "data-in-range") && has(props, "data-range-end");
const bleed = "calc(var(--sdp-gap, 0.25rem) / -2 - 0.5px)";
return (
<Button
type="text"
htmlType={type}
style={{
position: "relative",
justifySelf: "center",
width: DAY_SIZE,
minWidth: DAY_SIZE,
height: DAY_SIZE,
padding: 0,
overflow: "visible",
borderRadius: token.borderRadius,
fontSize: token.fontSizeSM + 1,
color: selected ? token.colorTextLightSolid : has(props, "data-outside") ? token.colorTextDisabled : token.colorText,
background: selected ? token.colorPrimary : undefined,
boxShadow: has(props, "data-today") && !selected ? `inset 0 0 0 ${token.lineWidth}px ${token.colorPrimary}` : "none",
...(has(props, "data-disabled") && { opacity: 0.3, cursor: "not-allowed" }),
...style,
}}
{...props}
>
{band && (
<span
data-band=""
aria-hidden="true"
style={{
position: "absolute",
zIndex: -1,
insetBlock: 0,
insetInlineStart: start ? 0 : bleed,
insetInlineEnd: end ? 0 : bleed,
background: has(props, "data-in-range") ? token.controlItemBgActive : token.controlItemBgHover,
borderStartStartRadius: start ? token.borderRadius : 0,
borderEndStartRadius: start ? token.borderRadius : 0,
borderStartEndRadius: end ? token.borderRadius : 0,
borderEndEndRadius: end ? token.borderRadius : 0,
}}
/>
)}
{children}
</Button>
);
},
/** The day number alone; the `Day` button around it carries every state. */
DayContent: ({ day }) => <span>{day.day}</span>,
/** The preset `Button`s under a hairline, and a link `Button` for today at the far end. */
Footer: function AntFooter({ presets, onPreset, onToday, todayLabel }) {
const { token } = theme.useToken();
return (
<Flex
wrap
align="center"
gap={token.paddingXXS}
style={{ marginTop: token.paddingXS, paddingTop: token.paddingXS, borderTop: `${token.lineWidth}px ${token.lineType} ${token.colorSplit}` }}
>
{presets.map((preset) => (
<Button key={preset.label} size="small" onClick={() => onPreset(preset.value)}>
{preset.label}
</Button>
))}
<Button type="link" size="small" onClick={onToday} style={{ marginInlineStart: "auto" }}>
{todayLabel}
</Button>
</Flex>
);
},
};Radix Themes
This adapter renders DatePicker with Radix Themes parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @radix-ui/react-icons @radix-ui/themes.
import { useState } from "react";
import { addDays, DatePicker, type DateRange } from "slotsmith/date-picker";
import { useToday } from "../../shared/today";
import { radixComponents } from "./radix";
export default function RadixDatePicker() {
const today = useToday();
const [stay, setStay] = useState<DateRange | null>(() => ({ from: addDays(today, 2), to: addDays(today, 5) }));
return (
<DatePicker
mode="range"
value={stay}
onChange={setStay}
presets={[
{ label: "Next 7 days", value: { from: today, to: addDays(today, 6) } },
{ label: "Tomorrow", value: addDays(today, 1) },
]}
components={radixComponents}
placeholder="Pick your dates"
aria-label="Stay"
/>
);
}import { useSyncExternalStore } from "react";
import { today, type ISODate } from "slotsmith/date-picker";
/** The date a prerendered page is built with, so its HTML is the same on every build. */
export const PRERENDER_TODAY: ISODate = "2026-03-16";
// Today does not change while the page is open, so there is nothing to subscribe to.
const subscribe = () => () => {};
/**
* Today as `YYYY-MM-DD`, safe to prerender: the fixed date on the server and
* while hydrating, so the markup matches, then the visitor's real date.
* An app that renders only in the browser can call `today()` directly.
*/
export function useToday(): ISODate {
return useSyncExternalStore(subscribe, today, () => PRERENDER_TODAY);
}The demo sits inside Radix Themes's provider, as any app built on it already does.
radix.tsx
/**
* DatePicker for Radix Themes
*
* A `components` map that renders the slotsmith date picker with Radix Themes
* primitives, tested against Radix Themes v3. Copy the file, keep the parts
* you want, and pass the map as `components={radixComponents}`; every slot
* left out keeps its fallback.
*
* This is Radix Themes (`@radix-ui/themes`), the styled library; an app on the
* bare Radix primitives wants the shadcn/ui adapter, which is built on them.
*/
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, CrossCircledIcon } from "@radix-ui/react-icons";
import { Button, Flex, IconButton, Popover, Select, Text, TextField } from "@radix-ui/themes";
import { useEffect, useId, useMemo, useState, type RefObject } from "react";
import { useDatePickerContext, type DatePickerComponents } from "slotsmith/date-picker";
/**
* Radix Themes date picker parts
*
* Built from Radix Themes v3 — `Button`, `IconButton`, `Flex`, `Text` and
* Radix icons — not from its `Popover`, which would position the calendar a
* second time. The surfaces take the look of Radix's fields and menus from
* its variables (`--accent-*`, `--gray-*`, `--color-panel-solid`), so they
* follow the app's `<Theme accentColor appearance>`; their state arrives as
* `data-*` props, so each part reads its own. The app must import
* `@radix-ui/themes/styles.css`, as every Radix Themes app already does.
*
* Radix's `Button` and `IconButton` render a `<button>` with no `type`, which
* would submit a surrounding form, so each one here is given
* `type="button"` ahead of the slot's props.
*
* The element parts `Omit` `color` from their DOM props because Radix's
* `color` prop is an accent colour, which a forwarded DOM `color` would
* collide with.
*/
/** The day cell, in pixels. Matches `--sdp-cell`, so the square fills its column. */
const DAY_SIZE = 32;
/**
* Has flag
*
* @param props - A part's props.
* @param name - A `data-*` attribute.
* @returns Whether the engine set it.
*/
const has = (props: object, name: `data-${string}`) => (props as Record<string, unknown>)[name] !== undefined;
/**
* Calendar sheet
*
* The calendar's own structure, which no slot renders: the header row, the
* seven columns of the weekday and week rows, and the centred cells. Radix's
* parts take inline styles, which cannot reach those, so a sheet scoped to
* the popup lays them out, and the picker holds together without
* slotsmith's stylesheet. Radix Themes offers no nonce setting: an app with
* a strict `style-src` policy adds its nonce to the `<style>` here.
*
* @param scope - The popup's scope class.
* @returns The sheet.
*/
function calendarSheet(scope: string): string {
const s = `.${scope}`;
return `${s} .sdp__header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-1); }
${s} :is(.sdp__weekdays, .sdp__week) { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
${s} :is(.sdp__columnheader, .sdp__cell) { display: grid; place-items: center; min-width: 0; }`;
}
export const radixComponents: Partial<DatePickerComponents> = {
Root: ({ style, ...props }) => <div style={{ position: "relative", width: "100%", maxWidth: 288, ...style }} {...props} />,
/**
* The surface field of Radix's `TextField`, with its focus outline while
* open. An invalid value switches it to the red scale with
* `data-accent-color="red"`, which is what Radix's own `color="red"` sets.
*/
Trigger: function RadixTrigger({ style, onFocus, onBlur, ...props }) {
/** Focus anywhere in the frame, so the text field gets the focus ring Radix's `TextField` shows. */
const [focused, setFocused] = useState(false);
const typing = (props as Record<string, unknown>)["data-trigger"] === "input";
const open = has(props, "data-open") || (typing && focused);
const disabled = has(props, "data-disabled");
const invalid = has(props, "data-invalid");
return (
<div
data-accent-color={invalid ? "red" : undefined}
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-2)",
width: "100%",
minHeight: "var(--space-6)",
paddingInline: "var(--space-2)",
color: disabled ? "var(--gray-a11)" : "var(--gray-12)",
fontFamily: "var(--default-font-family)",
fontSize: "var(--font-size-2)",
textAlign: "start",
background: disabled ? "var(--gray-a2)" : "var(--color-surface)",
boxShadow: `inset 0 0 0 1px ${invalid ? "var(--red-a8)" : "var(--gray-a7)"}`,
borderRadius: "var(--radius-2)",
outline: open ? `2px solid ${invalid ? "var(--red-8)" : "var(--focus-8)"}` : 0,
outlineOffset: -1,
cursor: disabled ? "not-allowed" : typing ? "text" : "pointer",
...style,
}}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
{...props}
/>
);
},
/**
* Radix's `TextField`, its own frame turned off: the trigger frame holds
* the border and the ring. `TextField.Root` hands the ref, the `role`, the
* aria wiring and the handlers to its `<input>`. Its `value` must be a
* string, which the field's text always is, and its `type` a text-like one,
* which it keeps by default.
*/
Input: ({ style, value, type: _type, defaultValue: _defaultValue, ...props }) => (
<TextField.Root
size="2"
variant="surface"
value={value === undefined ? undefined : String(value)}
style={{ flex: 1, minWidth: 0, boxShadow: "none", outline: "none", background: "transparent", ...style }}
{...props}
/>
),
/** A ghost `IconButton` around the calendar glyph. */
Toggle: ({ children, ...props }) => (
<IconButton size="1" variant="ghost" color="gray" {...props}>
{children}
</IconButton>
),
Value: ({ text, placeholder, empty }) => (
<Text size="2" truncate style={{ flex: 1, minWidth: 0, color: empty ? "var(--gray-a10)" : undefined }}>
{empty ? placeholder : text}
</Text>
),
Icon: ({ open }) => (
<span aria-hidden="true" style={{ display: "inline-flex", color: open ? "var(--accent-11)" : "var(--gray-a11)" }}>
<CalendarIcon />
</span>
),
Clear: ({ onClick, ...aria }) => (
<IconButton
type="button"
size="1"
variant="ghost"
color="gray"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
{...aria}
>
<CrossCircledIcon />
</IconButton>
),
/**
* Radix Themes' `Popover`, anchored to the trigger: it portals the
* calendar inside the theme, places it and draws Radix's panel. Focus stays
* with the picker, which moves it into the grid itself. The content keeps
* every prop the part is given, its `id` included, which is how the picker
* knows the portaled calendar is still part of it; the built-in position
* style is left out.
*/
Popup: function RadixPopup({ style: _style, className, children, ...props }) {
const { position } = useDatePickerContext();
const anchor = useMemo(() => ({ current: position.anchor }), [position.anchor]);
const scope = `ss-radix-calendar-${useId().replace(/[^\w-]/g, "")}`;
if (!position.anchor) return null;
return (
<Popover.Root open>
<Popover.Anchor virtualRef={anchor as RefObject<HTMLElement>} />
<Popover.Content
size="1"
width="288px"
align="start"
side={position.placement === "top-start" ? "top" : "bottom"}
sideOffset={4}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
// Its own stacking context, so the range band behind each day stays above the surface.
style={{ isolation: "isolate" }}
className={className ? `${scope} ${className}` : scope}
{...props}
>
<style>{calendarSheet(scope)}</style>
{children}
</Popover.Content>
</Popover.Root>
);
},
Calendar: ({ style, ...props }) => <div style={{ display: "grid", rowGap: 2, ...style }} {...props} />,
/**
* The month and year as Radix's `Select`, ghost triggers in the caption.
* Its menu renders into the calendar's own popup, so choosing a month is a
* press inside the picker and does not close it.
*/
Caption: function RadixCaption({ monthIndex, year, months, years, onMonthChange, onYearChange, labels }) {
const { ids } = useDatePickerContext();
const [popup, setPopup] = useState<HTMLElement | null>(null);
useEffect(() => setPopup(document.getElementById(ids.popup)), [ids.popup]);
return (
<Flex align="center" gap="1">
<Select.Root size="1" value={String(monthIndex)} onValueChange={(value) => onMonthChange(Number(value))}>
<Select.Trigger variant="ghost" color="gray" aria-label={labels.month} />
<Select.Content position="popper" container={popup}>
{months.map((name, index) => (
<Select.Item key={name} value={String(index)}>
{name}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Select.Root size="1" value={String(year)} onValueChange={(value) => onYearChange(Number(value))}>
<Select.Trigger variant="ghost" color="gray" aria-label={labels.year} />
<Select.Content position="popper" container={popup}>
{years.map((value) => (
<Select.Item key={value} value={String(value)}>
{value}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Flex>
);
},
Nav: ({ direction, onClick, disabled, ...aria }) => (
<IconButton type="button" size="1" variant="ghost" color="gray" onClick={onClick} disabled={disabled} style={{ margin: 0 }} {...aria}>
<span data-icon={direction === "next" ? "chevron-right" : "chevron-left"} style={{ display: "inline-flex" }}>
{direction === "next" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</span>
</IconButton>
),
/**
* One column header.
*
* No wrapping, because the column is one cell wide and locales whose short
* weekday names are full words would otherwise make the row two lines tall.
*/
Weekday: ({ label }) => (
<Text as="div" size="1" color="gray" align="center" style={{ paddingBlock: "var(--space-1)", whiteSpace: "nowrap" }}>
{label}
</Text>
),
/**
* One cell of the grid.
*
* A `Button`, pinned to the cell size: `solid` when picked, `ghost`
* otherwise, so the hover and the picked fill are Radix's own. Radix pulls
* ghost buttons out by their padding; the cell is a fixed square, so the
* margin is put back.
*
* Blocked days are styled from `data-disabled`, not Radix's `disabled`: the
* component keeps them focusable, so it never sets `disabled` on them.
*
* The range band is a span behind the button, at a negative z-index: it
* reaches half the gutter past each inline edge so neighbouring days meet
* across the gap, and stops square at the button's edge on the two ends,
* behind the picked day's rounded corners.
*/
Day: ({ style, children, ...props }) => {
const selected = has(props, "data-selected");
const band = has(props, "data-in-range") || has(props, "data-in-preview");
const start = has(props, "data-in-range") && has(props, "data-range-start");
const end = has(props, "data-in-range") && has(props, "data-range-end");
const bleed = "calc(var(--sdp-gap, 0.25rem) / -2 - 0.5px)";
return (
<Button
type="button"
variant={selected ? "solid" : "ghost"}
color={selected ? undefined : "gray"}
highContrast={!selected}
style={{
position: "relative",
justifySelf: "center",
width: DAY_SIZE,
height: DAY_SIZE,
margin: 0,
padding: 0,
overflow: "visible",
borderRadius: "var(--radius-2)",
fontWeight: "var(--font-weight-regular)",
color: selected ? undefined : has(props, "data-outside") ? "var(--gray-a9)" : undefined,
boxShadow: has(props, "data-today") && !selected ? "inset 0 0 0 1px var(--accent-8)" : "none",
...(has(props, "data-disabled") && { opacity: 0.3, cursor: "not-allowed" }),
...style,
}}
{...props}
>
{band && (
<span
data-band=""
aria-hidden="true"
style={{
position: "absolute",
zIndex: -1,
insetBlock: 0,
insetInlineStart: start ? 0 : bleed,
insetInlineEnd: end ? 0 : bleed,
backgroundColor: has(props, "data-in-range") ? "var(--accent-a4)" : "var(--accent-a3)",
borderStartStartRadius: start ? "var(--radius-2)" : 0,
borderEndStartRadius: start ? "var(--radius-2)" : 0,
borderStartEndRadius: end ? "var(--radius-2)" : 0,
borderEndEndRadius: end ? "var(--radius-2)" : 0,
}}
/>
)}
{children}
</Button>
);
},
DayContent: ({ day }) => <span>{day.day}</span>,
Footer: ({ presets, onPreset, onToday, todayLabel }) => (
<Flex wrap="wrap" align="center" gap="1" mt="2" pt="2" style={{ boxShadow: "inset 0 1px var(--gray-a5)" }}>
{presets.map((preset) => (
<Button type="button" key={preset.label} size="1" variant="soft" color="gray" onClick={() => onPreset(preset.value)}>
{preset.label}
</Button>
))}
<Button type="button" size="1" variant="ghost" onClick={onToday} style={{ marginBlock: 0, marginInlineStart: "auto", marginInlineEnd: 0 }}>
{todayLabel}
</Button>
</Flex>
),
};