Styled date picker
Five date pickers: simple, range, glass, inline and icon. Each is self-contained (no date library), sized, token-driven, and opens a month-grid calendar.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/date-picker-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/date-picker-styled.tsx"use client"
import * as React from "react"
import { Calendar, ChevronLeft, ChevronRight } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion, type Transition } from "motion/react"
import { cn } from "@/lib/utils"
/* Date picker family: 5 decorative self-contained date pickers. NO radix, NO native
input, NO portal, NO Intl. Each export is a complete picker: a relative
inline-flex wrapper + a real trigger (role="combobox", the selected date) + a
small month-grid calendar panel (role="grid") absolutely positioned BELOW the
trigger. Picking a day updates the value and closes the panel. It closes on an
outside click (window pointerdown, with inner clicks ignored via the wrapper ref)
and on Escape.
CRITICAL: to avoid an SSR/hydration mismatch the visible month derives from a
FIXED value (the month of the selected date if there is one, otherwise
FIXED_MONTH = 2025-01) - NEVER today. The date format is manual (YYYY-MM-DD),
with NO locale-dependent Intl -> deterministic. Controlled (value/onChange) +
uncontrolled (defaultValue). Color comes ONLY from tokens, via alpha color-mix.
Only a fade under reduced motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
/* Trigger yuksekligi base kontrol olcegiyle hizali (sm h-8, md h-9, lg h-10, xl h-12). */
const triggerHeight: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
/* SSR-guvenli sabit gorunen ay. today ASLA kullanilmaz. */
const FIXED_MONTH = new Date(2025, 0, 1)
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
/* Locale bagimsiz, deterministik format. */
function formatDate(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function sameDay(a: Date | undefined, b: Date | undefined): boolean {
return (
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function isBetween(day: Date, start: Date | undefined, end: Date | undefined): boolean {
if (!start || !end) return false
const t = day.getTime()
const lo = Math.min(start.getTime(), end.getTime())
const hi = Math.max(start.getTime(), end.getTime())
return t > lo && t < hi
}
/* Controlled/uncontrolled single-value management. */
function useControlledDate(
value: Date | undefined,
defaultValue: Date | undefined,
onChange?: (d: Date) => void
): [Date | undefined, (d: Date) => void] {
const isControlled = value !== undefined
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue)
const current = isControlled ? value : internal
const set = React.useCallback(
(d: Date) => {
if (!isControlled) setInternal(d)
onChange?.(d)
},
[isControlled, onChange]
)
return [current, set]
}
/* Escape + disari tiklama ile kapanma. */
function useDismiss(open: boolean, onClose: () => void, ref: React.RefObject<HTMLDivElement | null>) {
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
const onPointer = (e: PointerEvent) => {
const node = ref.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
onClose()
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open, onClose, ref])
}
const springTransition: Transition = { type: "spring", stiffness: 340, damping: 26 }
const panelMotion = {
initial: { opacity: 0, scale: 0.96, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -6 },
transition: springTransition,
}
const triggerBase =
"inline-flex w-full select-none items-center gap-2 whitespace-nowrap rounded-lg px-3 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const navBtn =
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0"
const dayBase =
"relative inline-flex size-8 items-center justify-center rounded-md text-sm text-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50"
const daySelected =
"bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--color-primary)_90%,transparent)]"
const dayRange =
"rounded-none bg-[color-mix(in_oklab,var(--color-primary)_18%,transparent)] text-foreground"
/* The shared calendar body: a header (the month name + prev/next) + a weekday row +
a role="grid" day grid. renderDay returns every real day as a <button>. */
function CalendarBody({
month,
onMonthChange,
renderDay,
className,
}: {
month: Date
onMonthChange: (d: Date) => void
renderDay: (day: Date) => React.ReactNode
className?: string
}) {
const year = month.getFullYear()
const m = month.getMonth()
const leading = new Date(year, m, 1).getDay()
const total = daysInMonth(year, m)
const cells: (Date | null)[] = []
for (let i = 0; i < leading; i++) cells.push(null)
for (let d = 1; d <= total; d++) cells.push(new Date(year, m, d))
return (
<div data-slot="styled-date-picker-calendar" className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center justify-between px-1">
<button
type="button"
aria-label="Previous month"
className={navBtn}
onClick={() => onMonthChange(new Date(year, m - 1, 1))}
>
<ChevronLeft />
</button>
<div className="text-sm font-semibold text-foreground">
{MONTH_NAMES[m]} {year}
</div>
<button
type="button"
aria-label="Next month"
className={navBtn}
onClick={() => onMonthChange(new Date(year, m + 1, 1))}
>
<ChevronRight />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5">
{WEEKDAY_LABELS.map((w) => (
<div
key={w}
className="flex size-8 items-center justify-center text-xs font-medium text-muted-foreground"
>
{w}
</div>
))}
</div>
<div role="grid" className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) =>
day === null ? (
<span key={`empty-${i}`} className="size-8" aria-hidden="true" />
) : (
<React.Fragment key={formatDate(day)}>{renderDay(day)}</React.Fragment>
)
)}
</div>
</div>
)
}
/* Ortak popover kabugu: wrapper + trigger + AnimatePresence panel. */
function PickerShell({
className,
trigger,
open,
setOpen,
panelClassName,
children,
}: {
className?: string
trigger: (open: boolean) => React.ReactNode
open: boolean
setOpen: (o: boolean) => void
panelClassName?: string
children: React.ReactNode
}) {
const reduce = useReducedMotion()
const wrapperRef = React.useRef<HTMLDivElement>(null)
useDismiss(open, () => setOpen(false), wrapperRef)
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const mp = reduce ? fade : panelMotion
return (
<div
ref={wrapperRef}
data-slot="styled-date-picker"
className={cn("relative inline-flex w-64 flex-col", className)}
>
<span
data-slot="styled-date-picker-trigger"
className="inline-flex"
onClick={() => setOpen(!open)}
>
{trigger(open)}
</span>
<AnimatePresence>
{open ? (
<motion.div
data-slot="styled-date-picker-popover"
className={cn(
"absolute left-0 top-full z-50 mt-2 w-64 origin-top rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg outline-none",
panelClassName
)}
initial={mp.initial}
animate={mp.animate}
exit={mp.exit}
transition={reduce ? ({ duration: 0.12 } as Transition) : panelMotion.transition}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
interface DatePickerProps {
className?: string
size?: StyledSize
placeholder?: string
value?: Date
defaultValue?: Date
onChange?: (d: Date) => void
}
/* SimpleDatePicker: cerceveli trigger + takvim popover. */
export function SimpleDatePicker({
className,
size = "md",
placeholder = "Pick a date",
value,
defaultValue,
onChange,
}: DatePickerProps) {
const [selected, setSelected] = useControlledDate(value, defaultValue, onChange)
const [open, setOpen] = React.useState(false)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const pick = (day: Date) => {
setSelected(day)
setMonth(startOfMonth(day))
setOpen(false)
}
return (
<PickerShell
className={className}
open={open}
setOpen={setOpen}
trigger={(isOpen) => (
<button
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="grid"
className={cn(
triggerBase,
triggerHeight[size],
"justify-between border border-field-border bg-transparent text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)]"
)}
>
<span className={cn(!selected && "text-muted-foreground")}>
{selected ? formatDate(selected) : placeholder}
</span>
<Calendar className="text-muted-foreground" />
</button>
)}
>
<CalendarBody
month={month}
onMonthChange={setMonth}
renderDay={(day) => (
<button
type="button"
role="gridcell"
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => pick(day)}
>
{day.getDate()}
</button>
)}
/>
</PickerShell>
)
}
/* RangeDatePicker: a start-to-end range trigger plus range selection in the popover. The shared signature (value/defaultValue: Date) seeds the start; the second click sets the end and onChange is called with the end day. */
export function RangeDatePicker({
className,
size = "md",
placeholder = "Pick a range",
value,
defaultValue,
onChange,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const [start, setStart] = React.useState<Date | undefined>(value ?? defaultValue)
const [end, setEnd] = React.useState<Date | undefined>(undefined)
const [month, setMonth] = React.useState(() => startOfMonth(value ?? defaultValue ?? FIXED_MONTH))
const pick = (day: Date) => {
if (!start || (start && end)) {
setStart(day)
setEnd(undefined)
onChange?.(day)
return
}
if (day.getTime() < start.getTime()) {
setStart(day)
onChange?.(day)
return
}
setEnd(day)
onChange?.(day)
setOpen(false)
}
const label =
start && end
? `${formatDate(start)} - ${formatDate(end)}`
: start
? `${formatDate(start)} - ...`
: placeholder
return (
<PickerShell
className={cn("w-72", className)}
panelClassName="w-72"
open={open}
setOpen={setOpen}
trigger={(isOpen) => (
<button
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="grid"
className={cn(
triggerBase,
triggerHeight[size],
"justify-between border border-field-border bg-transparent text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)]"
)}
>
<span className={cn(!start && "text-muted-foreground")}>{label}</span>
<Calendar className="text-muted-foreground" />
</button>
)}
>
<CalendarBody
month={month}
onMonthChange={setMonth}
renderDay={(day) => {
const isEnd = sameDay(day, start) || sameDay(day, end)
const inside = isBetween(day, start, end)
return (
<button
type="button"
role="gridcell"
aria-selected={isEnd}
className={cn(dayBase, inside && dayRange, isEnd && daySelected)}
onClick={() => pick(day)}
>
{day.getDate()}
</button>
)
}}
/>
</PickerShell>
)
}
/* GlassDatePicker: buzlu cam trigger + popover. */
export function GlassDatePicker({
className,
size = "md",
placeholder = "Pick a date",
value,
defaultValue,
onChange,
}: DatePickerProps) {
const [selected, setSelected] = useControlledDate(value, defaultValue, onChange)
const [open, setOpen] = React.useState(false)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const pick = (day: Date) => {
setSelected(day)
setMonth(startOfMonth(day))
setOpen(false)
}
const glass =
"border-border bg-[color-mix(in_oklab,var(--color-popover)_75%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-popover)_55%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
return (
<PickerShell
className={className}
panelClassName={glass}
open={open}
setOpen={setOpen}
trigger={(isOpen) => (
<button
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="grid"
className={cn(
triggerBase,
triggerHeight[size],
"justify-between border border-border text-foreground",
"bg-[color-mix(in_oklab,var(--color-surface-2)_70%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-surface-2)_45%,transparent)] supports-[backdrop-filter]:backdrop-blur-md hover:bg-[color-mix(in_oklab,var(--color-surface-2)_60%,transparent)]"
)}
>
<span className={cn(!selected && "text-muted-foreground")}>
{selected ? formatDate(selected) : placeholder}
</span>
<Calendar className="text-muted-foreground" />
</button>
)}
>
<CalendarBody
month={month}
onMonthChange={setMonth}
renderDay={(day) => (
<button
type="button"
role="gridcell"
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => pick(day)}
>
{day.getDate()}
</button>
)}
/>
</PickerShell>
)
}
/* InlineDatePicker: a calendar embedded under a small label, with NO trigger toggle. */
export function InlineDatePicker({
className,
size = "md",
placeholder = "Selected date",
value,
defaultValue,
onChange,
}: DatePickerProps) {
const [selected, setSelected] = useControlledDate(value, defaultValue, onChange)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const pick = (day: Date) => {
setSelected(day)
setMonth(startOfMonth(day))
}
const labelSize = size === "lg" || size === "xl" ? "text-sm" : "text-xs"
return (
<div
data-slot="styled-date-picker"
className={cn(
"inline-flex w-64 flex-col gap-2 rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-sm",
className
)}
>
<div className={cn("px-1 font-medium text-muted-foreground", labelSize)}>
{selected ? formatDate(selected) : placeholder}
</div>
<CalendarBody
month={month}
onMonthChange={setMonth}
renderDay={(day) => (
<button
type="button"
role="gridcell"
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => pick(day)}
>
{day.getDate()}
</button>
)}
/>
</div>
)
}
/* IconDatePicker: onde takvim ikonu olan minimal trigger + popover. */
export function IconDatePicker({
className,
size = "md",
placeholder = "Pick a date",
value,
defaultValue,
onChange,
}: DatePickerProps) {
const [selected, setSelected] = useControlledDate(value, defaultValue, onChange)
const [open, setOpen] = React.useState(false)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const pick = (day: Date) => {
setSelected(day)
setMonth(startOfMonth(day))
setOpen(false)
}
return (
<PickerShell
className={className}
open={open}
setOpen={setOpen}
trigger={(isOpen) => (
<button
type="button"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="grid"
className={cn(
triggerBase,
triggerHeight[size],
"justify-start border-b border-field-border bg-transparent px-1 text-foreground rounded-none hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)]"
)}
>
<Calendar className="text-muted-foreground" />
<span className={cn(!selected && "text-muted-foreground")}>
{selected ? formatDate(selected) : placeholder}
</span>
</button>
)}
>
<CalendarBody
month={month}
onMonthChange={setMonth}
renderDay={(day) => (
<button
type="button"
role="gridcell"
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => pick(day)}
>
{day.getDate()}
</button>
)}
/>
</PickerShell>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Simple
A trigger with a calendar popover.
import { SimpleDatePicker } from "@/components/ui/date-picker-styled"
<SimpleDatePicker />Range
A start-end range trigger and calendar.
import { RangeDatePicker } from "@/components/ui/date-picker-styled"
<RangeDatePicker />Glass
A frosted glass trigger and popover.
import { GlassDatePicker } from "@/components/ui/date-picker-styled"
<GlassDatePicker />Inline
The calendar is shown inline under a label.
import { InlineDatePicker } from "@/components/ui/date-picker-styled"
<InlineDatePicker />Icon
A minimal trigger with a calendar icon.
import { IconDatePicker } from "@/components/ui/date-picker-styled"
<IconDatePicker />ai2 Styled date picker: 5 styled variations on the token system
The ai2 Styled date picker are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around calendar-backed date pickers. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion fades the calendar popover in from the trigger. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the popover appears instantly with no animation.
What is in the ai2 Styled date picker?
5 exports in one file: Simple, Range, Glass, Inline and Icon. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion fades the calendar popover in from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the popover appears instantly with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled date picker sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.