Component
Date picker
A date field that opens a calendar to pick a single day.
- #arc
- #component
- #input
- #date
- #calendar
- #form
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/date-picker.jsonFiles
components/arc/date-picker/date-picker.module.csscomponents/arc/date-picker/date-picker.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdowndate-picker.module.cssdate-picker.module.cssdate-picker.module.csscss
.field { display: grid; gap: 7px; min-width: 0; }
.label { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.anchor { position: relative; min-width: 0; }
/* The trigger anchors the calendar, so press feedback stays in color; it never scales. */
.trigger { position: relative; display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: 9px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 12px; background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; transition: border-color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .trigger:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }
.trigger:active:not(:disabled), .trigger[aria-expanded="true"] { border-color: var(--border-strong); background: var(--surface-muted); }
.trigger:disabled { cursor: not-allowed; opacity: .5; }
.trigger > svg:first-child { flex: 0 0 auto; color: var(--text-muted); }
/* Old and new values cross in place; each part of a date is its own box so a wider day or month moves the rest along on a spring. */
.valueText { position: relative; display: flex; flex: 1; min-width: 0; }
.value, .part { position: relative; display: inline-flex; white-space: pre; }
.value { font-variant-numeric: tabular-nums; }
.partValue { display: inline-block; white-space: pre; }
.placeholder { min-width: 0; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }
.trigger[aria-expanded="true"] .chevron { transform: rotate(180deg); }
/* Grows out of the trigger's calendar icon and returns there on close. */
.popover { position: absolute; z-index: 20; top: calc(100% + 8px); left: 0; width: min(320px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); padding: 16px; background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: 22px -8px; will-change: transform, opacity; }
/* On phone widths the calendar matches the field instead of running past the screen edge, and a tighter inset keeps the month and its controls on one row. */
@media (max-width: 360px) { .popover { right: 0; width: auto; padding: 12px; } }
.footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; border-top: 1px solid var(--border-subtle); padding-top: 11px; color: var(--text-muted); font-size: var(--text-xs); }
/* Old and new status lines share one cell, pinned to the trailing edge so a shorter line never shifts. */
.status { display: grid; min-width: 0; justify-items: end; }
.status > span { grid-area: 1 / 1; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.footer button { border: 0; border-radius: var(--radius-control); padding: 4px 8px; background: transparent; color: var(--text-secondary); font: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
@media (hover: hover) and (pointer: fine) { .footer button:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); } }
.footer button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard); }
.footer button:disabled { cursor: not-allowed; opacity: .4; }
.description { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); transition: opacity var(--duration-fast) var(--ease-standard); }
/* The open calendar covers the hint; fading it keeps its first letters from peeking past the popover's rounded corner. */
.anchor:has(.popover) + .description { opacity: 0; }
@media (prefers-reduced-motion: reduce) { .trigger, .chevron, .footer button, .description { transition: none; } .footer button:active:not(:disabled) { transform: none; } }
date-picker.tsxdate-picker.tsxdate-picker.tsxtsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ButtonHTMLAttributes, FocusEvent, KeyboardEvent } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { Calendar, type CalendarDateMatcher } from "../calendar/calendar";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./date-picker.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface DatePickerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange"> {
label: string;
value?: Date;
onChange?: (date: Date | undefined) => void;
description?: string;
placeholder?: string;
minDate?: Date;
maxDate?: Date;
disabledDates?: CalendarDateMatcher;
locale?: string;
format?: Intl.DateTimeFormatOptions;
/** 달력 머리글에 오늘 버튼을 넣어요. */
showToday?: boolean;
}
const monthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
const { spring, duration, ease, blur } = motionTokens;
const instant = { duration: duration.instant };
/** Each part of the date rolls with time: a later date rises from below, an earlier one drops from above. Parts that did not change stay still. */
const valueRoll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.35}em`, filter: `blur(${blur.soft}px)` }),
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: ease.enter } },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: ease.standard } }),
};
/** Reduced motion keeps a short crossfade; resting values match the roll so server and client markup agree. */
const valueFade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: 0, filter: "blur(0px)", transition: instant }, exit: { opacity: 0, transition: instant } };
/** The footer confirms the pick: new copy rises in with a soft blur while the old line lifts away. */
const statusRise: Variants = {
enter: { opacity: 0, y: "0.3em", filter: `blur(${blur.soft}px)` },
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: ease.enter } },
exit: { opacity: 0, y: "-0.3em", filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: ease.standard } },
};
export function DatePicker({ label, value, onChange, description, placeholder = "날짜를 선택해 주세요", minDate, maxDate, disabledDates, locale = "ko-KR", format = { month: "short", day: "numeric", year: "numeric" }, showToday, id, className, disabled, ...buttonProps }: DatePickerProps) {
const generatedId = useId();
const controlId = id ?? generatedId;
const hintId = description ? `${controlId}-description` : undefined;
const [open, setOpen] = useState(false);
const [month, setMonth] = useState(monthStart(value ?? new Date()));
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number | undefined>(undefined);
const formatter = new Intl.DateTimeFormat(locale, format);
const reduce = useReducedMotion() ?? false;
const time = value?.getTime() ?? null;
const [previousTime, setPreviousTime] = useState(time);
const [direction, setDirection] = useState(1);
if (previousTime !== time) { setPreviousTime(time); setDirection(time === null || previousTime === null || time >= previousTime ? 1 : -1); }
const shown = value ? formatter.format(value) : placeholder;
const parts = value ? formatter.formatToParts(value) : [];
// Parts after a wider or narrower segment move on the same curve the new text arrives on, so they never overlap it.
const partMotion = reduce ? { duration: 0 } : { duration: duration.standard, ease: ease.enter };
useEffect(() => {
const onPointerDown = (event: PointerEvent) => { if (open && !rootRef.current?.contains(event.target as Node)) { playUiSound("close"); window.clearTimeout(closeTimer.current); setOpen(false); } };
document.addEventListener("pointerdown", onPointerDown);
return () => { document.removeEventListener("pointerdown", onPointerDown); window.clearTimeout(closeTimer.current); };
}, [open]);
/** Opening moves focus to the day that owns the tab stop, so arrows work at once; a pointer open shows no ring. */
useEffect(() => {
if (open) popoverRef.current?.querySelector<HTMLButtonElement>("[data-present] [data-date][tabindex=\"0\"]")?.focus({ preventScroll: true });
}, [open]);
/** Closing hands focus back to the trigger when it was inside the calendar, so keyboard users never land on the page body. */
// A pick plays its own sound, so the close that follows it is quiet.
const close = (quiet = false) => {
if (!quiet) playUiSound("close");
window.clearTimeout(closeTimer.current);
if (popoverRef.current?.contains(document.activeElement)) triggerRef.current?.focus();
setOpen(false);
};
/** The calendar always opens on the month of the current value, or today's month. */
const show = () => { playUiSound("open"); window.clearTimeout(closeTimer.current); setMonth(monthStart(value ?? new Date())); setOpen(true); };
/** A picked day lets the highlight glide onto it and the footer confirm it, then the calendar returns to the field. */
const selectDate = (date: Date | undefined) => {
// Picking a day is already voiced by the calendar; only clearing needs its own sound.
if (!date) playUiSound("select");
onChange?.(date);
window.clearTimeout(closeTimer.current);
if (reduce || !date) close(true);
else closeTimer.current = window.setTimeout(() => close(true), 300);
};
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") && !open) { event.preventDefault(); show(); }
if (event.key === "Escape" && open) close();
};
/** Tabbing past the popover closes it; focus moving within the field keeps it open. */
const onBlur = (event: FocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (open && next && !rootRef.current?.contains(next)) { playUiSound("close"); window.clearTimeout(closeTimer.current); setOpen(false); }
};
return <div className={[styles.field, className].filter(Boolean).join(" ")} ref={rootRef} onBlur={onBlur}>
<label className={styles.label} htmlFor={controlId}>{label}</label>
<div className={styles.anchor}>
<button {...buttonProps} ref={triggerRef} id={controlId} type="button" disabled={disabled} aria-haspopup="dialog" aria-expanded={open} aria-describedby={hintId} className={styles.trigger} onClick={() => (open ? close() : show())} onKeyDown={onTriggerKeyDown}>
<UiIcon iconRole="calendar" size={16} strokeWidth={1.75} aria-hidden="true" />
<span className={styles.srOnly}>{shown}</span>
<span className={styles.valueText} aria-hidden="true">
<AnimatePresence mode="popLayout" initial={false} custom={1}>
{value ? <motion.span key="value" className={styles.value} custom={1} variants={reduce ? valueFade : valueRoll} initial="enter" animate="center" exit="exit">
{parts.map((part, index) => <motion.span key={`${index}-${part.type}`} className={styles.part} layout="position" layoutDependency={time} transition={partMotion}>
{part.type === "literal" ? <span className={styles.partValue}>{part.value}</span> : <AnimatePresence mode="popLayout" initial={false} custom={direction}><motion.span key={part.value} className={styles.partValue} custom={direction} variants={reduce ? valueFade : valueRoll} initial="enter" animate="center" exit="exit">{part.value}</motion.span></AnimatePresence>}
</motion.span>)}
</motion.span> : <motion.span key="placeholder" className={styles.placeholder} custom={-1} variants={reduce ? valueFade : valueRoll} initial="enter" animate="center" exit="exit">{placeholder}</motion.span>}
</AnimatePresence>
</span>
<UiIcon iconRole="chevron-down" className={styles.chevron} size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
<AnimatePresence>
{open && <motion.div ref={popoverRef} className={styles.popover} role="dialog" aria-label={`${label} 달력`} onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } }}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: .95 }}
animate={{ opacity: 1, y: 0, scale: 1, transition: reduce ? instant : { ...spring.snappy, opacity: { duration: duration.fast, ease: ease.enter } } }}
exit={{ opacity: 0, ...(reduce ? {} : { y: -6, scale: .97 }), transition: { duration: motionTokens.duration.instant, ease: ease.standard } }}>
<Calendar value={value} onChange={selectDate} month={month} onMonthChange={setMonth} minDate={minDate} maxDate={maxDate} disabledDates={disabledDates} locale={locale} showToday={showToday} />
<div className={styles.footer}><button type="button" onClick={() => selectDate(undefined)} disabled={!value}>지우기</button><span className={styles.status}><AnimatePresence initial={false}><motion.span key={time ?? "none"} variants={reduce ? valueFade : statusRise} initial="enter" animate="center" exit="exit">{value ? `${formatter.format(value)} 선택했어요` : "날짜를 골라 주세요"}</motion.span></AnimatePresence></span></div>
</motion.div>}
</AnimatePresence>
</div>
{description && <span id={hintId} className={styles.description}>{description}</span>}
</div>;
}
Used items
- CalendarComponent
A monthly calendar for picking a date, with keyboard navigation.