{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-picker",
  "type": "registry:ui",
  "title": "Time picker",
  "description": "Choose a time with sensible keyboard behavior.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/time-picker/time-picker.tsx",
      "type": "registry:component",
      "target": "@components/xzero/time-picker/time-picker.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { ChevronDown, Clock3 } from \"lucide-react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./time-picker.module.css\";\n\nexport interface TimePickerProps {\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  description?: string;\n  placeholder?: string;\n  minuteStep?: 1 | 5 | 10 | 15 | 30;\n  format?: \"12h\" | \"24h\";\n  disabled?: boolean;\n  className?: string;\n}\n\nconst pad = (value: number) => String(value).padStart(2, \"0\");\nconst toMinutes = (value: string) => { const [h = NaN, m = NaN] = value.split(\":\").map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0; };\nconst enter = motionTokens.ease.enter;\nconst standard = motionTokens.ease.standard;\n/** The shown time rolls like a clock face: a later time rises from below, an earlier one drops from above. */\nconst valueRoll: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.35}em`, filter: `blur(${motionTokens.blur.soft}px)` }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: enter } },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.3}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }),\n};\n/** Reduced motion keeps a short crossfade; the resting state matches valueRoll so server and client markup agree. */\nconst 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 } } };\n\nexport function TimePicker({ label, value, defaultValue = \"09:00\", onChange, description, placeholder = \"Select a time\", minuteStep = 15, format = \"12h\", disabled = false, className }: TimePickerProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const valueId = `${id}-value`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const centerOnOpen = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [open, setOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const selected = value ?? internal;\n  const reduce = useReducedMotion();\n  const [previous, setPrevious] = useState(selected);\n  const [direction, setDirection] = useState(1);\n  if (previous !== selected) { setPrevious(selected); setDirection(toMinutes(selected) >= toMinutes(previous) ? 1 : -1); }\n  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)}`; });\n  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 || 12}:${pad(minute)} ${hour < 12 ? \"AM\" : \"PM\"}`; };\n  useEffect(() => { const close = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener(\"pointerdown\", close); return () => document.removeEventListener(\"pointerdown\", close); }, []);\n  // Runs before paint so the menu's first frame is already centered on the selected time.\n  useLayoutEffect(() => {\n    if (!open || activeIndex < 0) return;\n    // Scroll only the menu, never the page: the selected time opens centered, arrow keys then keep the highlight in view.\n    const option = optionRefs.current[activeIndex];\n    const menu = option?.parentElement;\n    if (!option || !menu) return;\n    const top = option.offsetTop;\n    const bottom = top + option.offsetHeight;\n    if (centerOnOpen.current) { centerOnOpen.current = false; menu.scrollTop = top - (menu.clientHeight - option.offsetHeight) / 2; }\n    else if (top < menu.scrollTop) menu.scrollTop = top;\n    else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;\n  }, [activeIndex, open]);\n  const choose = (next: string) => { if (value === undefined) setInternal(next); onChange?.(next); setOpen(false); };\n  const openMenu = () => {\n    const selectedIndex = options.indexOf(selected);\n    setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);\n    centerOnOpen.current = true;\n    setOpen(true);\n  };\n  const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"Escape\") { event.preventDefault(); setOpen(false); return; }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (open && activeIndex >= 0) choose(options[activeIndex]!);\n      else openMenu();\n      return;\n    }\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) { openMenu(); return; }\n      setActiveIndex(index => (index + (event.key === \"ArrowDown\" ? 1 : -1) + options.length) % options.length);\n    }\n  };\n  return <div ref={rootRef} className={[styles.field, className].filter(Boolean).join(\" \")}>\n    <span id={labelId} className={styles.label}>{label}</span>\n    <div className={styles.anchor}>\n      <button type=\"button\" className={styles.trigger} disabled={disabled} aria-labelledby={`${labelId} ${valueId}`} aria-haspopup=\"listbox\" aria-expanded={open} aria-controls={`${id}-listbox`} onClick={() => open ? setOpen(false) : openMenu()} onKeyDown={onKeyDown}>\n        <Clock3 size={16} aria-hidden=\"true\" />\n        <span id={valueId} className={styles.srOnly}>{selected ? display(selected) : placeholder}</span>\n        <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>\n        <ChevronDown className={styles.chevron} size={16} aria-hidden=\"true\" />\n      </button>\n      <AnimatePresence initial={false}>{open && <motion.div id={`${id}-listbox`} className={styles.menu} role=\"listbox\" aria-label={`${label} options`} \n        initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: .97 }}\n        animate={{ opacity: 1, y: 0, scale: 1, transition: reduce ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } }}\n        exit={{ opacity: 0, ...(reduce ? {} : { y: -4, scale: .98 }), transition: { duration: motionTokens.duration.instant, ease: standard } }}>\n        {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>)}\n      </motion.div>}</AnimatePresence>\n    </div>\n    {description && <span className={styles.description}>{description}</span>}\n  </div>;\n}\n"
    },
    {
      "path": "registry/components/time-picker/time-picker.module.css",
      "type": "registry:component",
      "target": "@components/xzero/time-picker/time-picker.module.css",
      "content": ".field { display: grid; gap: var(--space-2); min-width: 0; }\n.label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.anchor { position: relative; min-width: 0; }\n/* The trigger anchors the menu, so press feedback stays in color; it never scales. */\n.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); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }\n.trigger:active:not(:disabled), .trigger[aria-expanded=\"true\"] { border-color: var(--border-strong); background: var(--surface-muted); }\n.trigger:disabled { opacity: .5; cursor: not-allowed; }\n.trigger > svg:first-child { flex: 0 0 auto; color: var(--text-muted); }\n.valueText { display: grid; flex: 1; min-width: 0; grid-template-columns: minmax(0, 1fr); }\n.valueText > span { grid-area: 1 / 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.value { font-variant-numeric: tabular-nums; }\n.placeholder, .description { color: var(--text-muted); }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }\n.chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }\n.trigger[aria-expanded=\"true\"] .chevron { transform: rotate(180deg); }\n.description { font-size: var(--text-xs); }\n.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(--border); border-radius: var(--radius-panel); padding: 5px; background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: top center; will-change: transform, opacity; }\n/* Arrow keys move the highlight often, so it changes almost instantly. The dot marks the chosen time, so only one row is ever highlighted. */\n.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 80ms var(--ease-standard); }\n.option[data-active=\"true\"] { background: var(--surface-muted); }\n.dot { width: 6px; height: 6px; border-radius: 999px; background: var(--foreground); }\n@media (prefers-reduced-motion: reduce) { .trigger, .chevron, .option { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/time-picker",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "time",
      "field"
    ],
    "docs": "https://ui.xzero.co.uk/components/time-picker",
    "markdown": "https://ui.xzero.co.uk/components/time-picker/markdown"
  }
}