Motion date pickers
Five date pickers that share one calendar and vary only the popover opening: a slide, a corner pop, a plain fade, a spring and a vertical morph. Each is self-contained (no date library), sized, token-driven, and closes on day select, outside click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/date-picker-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/date-picker-motion.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"
/* Motion date picker family: 5 self-contained pickers. The difference is ONLY in the
popover's opening motion: slide, pop, fade, spring, morph. NO radix, NO date
library, NO portal, NO Intl. Each export is a complete picker: a relative wrapper
+ a trigger (role="combobox" + aria-haspopup="grid", aria-expanded) + a 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 or
Escape (focus returns to the trigger).
CRITICAL: the visible month derives from a FIXED value (the month of the selected
date if there is one, otherwise FIXED_MONTH = 2026-01) - NEVER today. Reading the
real clock breaks prerender/hydration determinism. Formatting is manual
(YYYY-MM-DD), NO Intl. Color comes ONLY from tokens, via alpha color-mix. Only a
fade under reduced motion.
State trap: the selection state is held OUTSIDE THE POPOVER TREE - the panel
unmounts on close, and the selection lives on. */
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(2026, 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())}`
}
/* Gun butonlarinin erisilebilir adi: tam tarih. */
function formatLong(d: Date): string {
return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
}
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()
)
}
/* Closes on Escape and on an outside click; Escape returns focus to the trigger. */
function useDismiss(
open: boolean,
onClose: (refocus: boolean) => void,
ref: React.RefObject<HTMLDivElement | null>
) {
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(true)
}
const onPointer = (e: PointerEvent) => {
const node = ref.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
onClose(false)
}
}
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" as const, stiffness: 340, damping: 26 }
interface PanelMotion {
initial: Record<string, number>
animate: Record<string, number>
exit: Record<string, number>
transition: Transition
origin: string
}
/* Her varyantin acilis hareketi. */
const slideMotion: PanelMotion = {
initial: { opacity: 0, y: -12 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12 },
transition: { duration: 0.2, ease: "easeOut" },
origin: "origin-top",
}
const popMotion: PanelMotion = {
initial: { opacity: 0, scale: 0.85 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.85 },
transition: { type: "spring" as const, stiffness: 520, damping: 22 },
origin: "origin-top-left",
}
const fadeMotion: PanelMotion = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.24, ease: "easeInOut" },
origin: "origin-top",
}
const springMotion: PanelMotion = {
initial: { opacity: 0, scale: 0.9, y: -14 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.94, y: -8 },
transition: springTransition,
origin: "origin-top",
}
const morphMotion: PanelMotion = {
initial: { opacity: 0, scaleY: 0.4, scaleX: 0.9 },
animate: { opacity: 1, scaleY: 1, scaleX: 1 },
exit: { opacity: 0, scaleY: 0.4, scaleX: 0.9 },
transition: { duration: 0.26, ease: "easeOut" },
origin: "origin-top",
}
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 [&_i]:text-base [&_i]:leading-none"
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)]"
/* Ortak takvim govdesi: baslik (ay adi + prev/next) + weekday satiri + role="grid"
gun izgarasi. */
function CalendarBody({
month,
onMonthChange,
selected,
onPick,
labelId,
}: {
month: Date
onMonthChange: (d: Date) => void
selected: Date | undefined
onPick: (d: Date) => void
labelId: 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="flex flex-col gap-2">
<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 id={labelId} 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" aria-labelledby={labelId} className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) =>
day === null ? (
<span key={`empty-${i}`} className="size-8" aria-hidden="true" />
) : (
<button
key={formatDate(day)}
type="button"
role="gridcell"
aria-label={formatLong(day)}
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => onPick(day)}
>
{day.getDate()}
</button>
)
)}
</div>
</div>
)
}
export interface DatePickerProps {
className?: string
size?: StyledSize
placeholder?: string
value?: Date
defaultValue?: Date
onChange?: (d: Date) => void
}
/* Shared body: the selection state lives OUTSIDE the popover. */
function MotionPicker({
className,
size = "md",
placeholder = "Pick a date",
value,
defaultValue,
onChange,
spec,
}: DatePickerProps & { spec: PanelMotion }) {
const isControlled = value !== undefined
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue)
const selected = isControlled ? value : internal
const [open, setOpen] = React.useState(false)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const reduce = useReducedMotion()
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const panelId = React.useId()
const labelId = React.useId()
const close = React.useCallback((refocus: boolean) => {
setOpen(false)
if (refocus) triggerRef.current?.focus()
}, [])
useDismiss(open, close, wrapperRef)
const pick = (day: Date) => {
if (!isControlled) setInternal(day)
onChange?.(day)
setMonth(startOfMonth(day))
setOpen(false)
triggerRef.current?.focus()
}
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const mp = reduce ? fade : spec
return (
<div
ref={wrapperRef}
data-slot="styled-date-picker"
className={cn("relative inline-flex w-64 flex-col", className)}
>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-haspopup="grid"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
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)]"
)}
onClick={() => setOpen(!open)}
>
<span className={cn(!selected && "text-muted-foreground")}>
{selected ? formatDate(selected) : placeholder}
</span>
<Calendar className="text-muted-foreground" />
</button>
<AnimatePresence>
{open ? (
<motion.div
id={panelId}
data-slot="styled-date-picker-popover"
className={cn(
"absolute left-0 top-full z-50 mt-2 w-64 rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg outline-none",
reduce ? "origin-top" : spec.origin
)}
initial={mp.initial}
animate={mp.animate}
exit={mp.exit}
transition={reduce ? ({ duration: 0.12 } as Transition) : spec.transition}
>
<CalendarBody
month={month}
onMonthChange={setMonth}
selected={selected}
onPick={pick}
labelId={labelId}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/* SlideDatePicker: panel ustten asagi kayarak girer. */
export function SlideDatePicker(props: DatePickerProps) {
return <MotionPicker {...props} spec={slideMotion} />
}
/* PopDatePicker: sol ust kosesinden yaylanarak buyur. */
export function PopDatePicker(props: DatePickerProps) {
return <MotionPicker {...props} spec={popMotion} />
}
/* FadeDatePicker: an opacity transition only, no transform. */
export function FadeDatePicker(props: DatePickerProps) {
return <MotionPicker {...props} spec={fadeMotion} />
}
/* SpringDatePicker: yay fizigiyle olcek + kayma birlikte. */
export function SpringDatePicker(props: DatePickerProps) {
return <MotionPicker {...props} spec={springMotion} />
}
/* MorphDatePicker: enters by opening vertically (scaleY) as if it grew out of the trigger. */
export function MorphDatePicker(props: DatePickerProps) {
return <MotionPicker {...props} spec={morphMotion} />
}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.
Slide
The calendar slides down from the trigger.
import { SlideDatePicker } from "@/components/ui/date-picker-motion"
<SlideDatePicker />Pop
The calendar springs open from its top-left corner.
import { PopDatePicker } from "@/components/ui/date-picker-motion"
<PopDatePicker />Fade
The calendar cross-fades in with no transform.
import { FadeDatePicker } from "@/components/ui/date-picker-motion"
<FadeDatePicker />Spring
Spring physics drive scale and travel together.
import { SpringDatePicker } from "@/components/ui/date-picker-motion"
<SpringDatePicker />Morph
The calendar unfolds vertically out of the trigger.
import { MorphDatePicker } from "@/components/ui/date-picker-motion"
<MorphDatePicker />ai2 Motion date pickers: 5 styled variations on the token system
The ai2 Motion date pickers are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around calendar date pickers that vary the popover opening motion. 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 animates the calendar enter and exit 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 transforms are skipped and the calendar fades in.
What is in the ai2 Motion date pickers?
5 exports in one file: Slide, Pop, Fade, Spring and Morph. 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 animates the calendar enter and exit from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and the calendar fades in.
- 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 Motion date pickers 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.