컴포넌트
시간 선택
시간을 골라 입력하고, 바뀐 시각이 시계처럼 굴러 바뀌어요.
- #arc
- #component
- #time
- #input
- #form
- #picker
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/time-picker.json파일
components/arc/time-picker/time-picker.module.csscomponents/arc/time-picker/time-picker.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
time-picker.module.csstime-picker.module.csstime-picker.module.csscss
.field { display: grid; gap: var(--space-2); min-width: 0; }
.label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.anchor { position: relative; min-width: 0; }
/* The trigger anchors the menu, 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-color 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 { opacity: .5; cursor: not-allowed; }
.trigger > svg:first-child { flex: 0 0 auto; color: var(--text-muted); }
.valueText { display: grid; flex: 1; min-width: 0; grid-template-columns: minmax(0, 1fr); }
.valueText > span { grid-area: 1 / 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.value { font-variant-numeric: tabular-nums; }
.placeholder, .description { color: var(--text-muted); }
.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); }
.description { font-size: var(--text-xs); }
.menu { position: absolute; z-index: 80; top: calc(100% + 6px); right: 0; left: 0; max-height: 250px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); padding: 5px; background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: top center; will-change: transform, opacity; }
/* Arrow keys move the highlight often, so it changes almost instantly. The dot marks the chosen time, so only one row is ever highlighted. */
.option { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; border: 0; border-radius: calc(var(--radius-panel) - 5px); padding: 0 12px; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; transition: background-color var(--duration-micro) var(--ease-standard); }
.option[data-active="true"] { background: var(--surface-muted); }
.dot { width: 6px; height: 6px; border-radius: 999px; background: var(--foreground); }
@media (prefers-reduced-motion: reduce) { .trigger, .chevron, .option { transition: none; } }
time-picker.tsxtime-picker.tsxtime-picker.tsxtsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./time-picker.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface TimePickerProps {
label: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
description?: string;
placeholder?: string;
minuteStep?: 1 | 5 | 10 | 15 | 30;
format?: "12h" | "24h";
disabled?: boolean;
className?: string;
}
const pad = (value: number) => String(value).padStart(2, "0");
const toMinutes = (value: string) => { const [h = NaN, m = NaN] = value.split(":").map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0; };
const enter = motionTokens.ease.enter;
const standard = motionTokens.ease.standard;
/** The shown time rolls like a clock face: a later time rises from below, an earlier one drops from above. */
const valueRoll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.35}em`, filter: `blur(${motionTokens.blur.soft}px)` }),
center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: enter } },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }),
};
/** Reduced motion keeps a short crossfade; the resting state matches valueRoll so server and client markup agree. */
const valueFade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
export function TimePicker({ label, value, defaultValue = "09:00", onChange, description, placeholder = "시간을 선택해 주세요", minuteStep = 15, format = "24h", disabled = false, className }: TimePickerProps) {
const id = useId();
const labelId = `${id}-label`;
const valueId = `${id}-value`;
const rootRef = useRef<HTMLDivElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const centerOnOpen = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const selected = value ?? internal;
const reduce = useReducedMotion();
const [previous, setPrevious] = useState(selected);
const [direction, setDirection] = useState(1);
if (previous !== selected) { setPrevious(selected); setDirection(toMinutes(selected) >= toMinutes(previous) ? 1 : -1); }
const options = Array.from({ length: Math.ceil(1440 / minuteStep) }, (_, index) => { const minutes = index * minuteStep; const hour = Math.floor(minutes / 60); const minute = minutes % 60; return `${pad(hour)}:${pad(minute)}`; });
const display = (raw: string) => { const minutes = toMinutes(raw); const hour = Math.floor(minutes / 60); const minute = minutes % 60; return format === "24h" ? `${pad(hour)}:${pad(minute)}` : `${hour < 12 ? "오전" : "오후"} ${hour % 12 || 12}:${pad(minute)}`; };
useEffect(() => { if (!open) return; const close = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) { playUiSound("close"); setOpen(false); } }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, [open]);
// Runs before paint so the menu's first frame is already centered on the selected time.
useLayoutEffect(() => {
if (!open || activeIndex < 0) return;
// Scroll only the menu, never the page: the selected time opens centered, arrow keys then keep the highlight in view.
const option = optionRefs.current[activeIndex];
const menu = option?.parentElement;
if (!option || !menu) return;
const top = option.offsetTop;
const bottom = top + option.offsetHeight;
if (centerOnOpen.current) { centerOnOpen.current = false; menu.scrollTop = top - (menu.clientHeight - option.offsetHeight) / 2; }
else if (top < menu.scrollTop) menu.scrollTop = top;
else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
}, [activeIndex, open]);
const choose = (next: string) => { playUiSound("select"); if (value === undefined) setInternal(next); onChange?.(next); setOpen(false); };
const openMenu = () => {
const selectedIndex = options.indexOf(selected);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
centerOnOpen.current = true;
playUiSound("open");
setOpen(true);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Escape") { event.preventDefault(); if (open) { playUiSound("close"); setOpen(false); } return; }
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (open && activeIndex >= 0) choose(options[activeIndex]!);
else openMenu();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) { openMenu(); return; }
setActiveIndex(index => (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length);
}
};
return <div ref={rootRef} className={[styles.field, className].filter(Boolean).join(" ")}>
<span id={labelId} className={styles.label}>{label}</span>
<div className={styles.anchor}>
<button type="button" className={styles.trigger} disabled={disabled} aria-labelledby={`${labelId} ${valueId}`} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-listbox`} onClick={() => { if (open) { playUiSound("close"); setOpen(false); } else openMenu(); }} onKeyDown={onKeyDown}>
<UiIcon iconRole="clock" size={16} aria-hidden="true" />
<span id={valueId} className={styles.srOnly}>{selected ? display(selected) : placeholder}</span>
<span className={styles.valueText} aria-hidden="true"><AnimatePresence initial={false} custom={direction}><motion.span key={selected || "placeholder"} className={selected ? styles.value : styles.placeholder} custom={direction} variants={reduce ? valueFade : valueRoll} initial="enter" animate="center" exit="exit">{selected ? display(selected) : placeholder}</motion.span></AnimatePresence></span>
<UiIcon iconRole="chevron-down" className={styles.chevron} size={16} aria-hidden="true" />
</button>
<AnimatePresence initial={false}>{open && <motion.div id={`${id}-listbox`} className={styles.menu} role="listbox" aria-label={`${label} 시간 목록`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }}
animate={{ opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }}
exit={{ opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }}>
{options.map((option, index) => <button ref={node => { optionRefs.current[index] = node; }} id={`${id}-option-${index}`} type="button" role="option" aria-selected={option === selected} data-active={activeIndex === index || undefined} className={styles.option} key={option} onPointerMove={() => { if (activeIndex !== index) setActiveIndex(index); }} onClick={() => choose(option)}>{display(option)}{option === selected && <span className={styles.dot} aria-hidden="true" />}</button>)}
</motion.div>}</AnimatePresence>
</div>
{description && <span className={styles.description}>{description}</span>}
</div>;
}