{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar",
  "type": "registry:ui",
  "title": "Calendar",
  "description": "Browse dates in a clear, compact month view.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/calendar/calendar.tsx",
      "type": "registry:component",
      "target": "@components/xzero/calendar/calendar.tsx",
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { animate, AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { useToday } from \"../lib/use-today\";\nimport styles from \"./calendar.module.css\";\n\nexport type CalendarDateMatcher = (date: Date) => boolean;\n\nexport interface CalendarProps {\n  value?: Date;\n  onChange?: (date: Date) => void;\n  month?: Date;\n  onMonthChange?: (month: Date) => void;\n  minDate?: Date;\n  maxDate?: Date;\n  disabledDates?: CalendarDateMatcher;\n  locale?: string;\n  className?: string;\n  /** Adds a Today button that slides back to the current month and selects today when it is available. */\n  showToday?: boolean;\n}\n\nconst startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());\nconst monthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);\nconst sameDay = (a?: Date, b?: Date) => Boolean(a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate());\nconst sameMonth = (a?: Date, b?: Date) => Boolean(a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth());\nconst addDays = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\nconst addMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, 1);\n/** Moves by whole months and keeps the day, clamped to the shorter month: January 31 plus one month is February 28. */\nconst shiftMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, Math.min(date.getDate(), new Date(date.getFullYear(), date.getMonth() + amount + 1, 0).getDate()));\nconst isBefore = (a: Date, b?: Date) => Boolean(b && startOfDay(a).getTime() < startOfDay(b).getTime());\nconst isAfter = (a: Date, b?: Date) => Boolean(b && startOfDay(a).getTime() > startOfDay(b).getTime());\nconst dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, \"0\")}-${String(date.getDate()).padStart(2, \"0\")}`;\n/** Every month shows six weeks, so the grid keeps one height and never jumps while months change. */\nconst makeWeeks = (month: Date) => {\n  const start = addDays(month, -month.getDay());\n  return Array.from({ length: 6 }, (_, week) => Array.from({ length: 7 }, (_, day) => addDays(start, week * 7 + day)));\n};\n\nconst subscribeNothing = () => () => {};\nconst clientSnapshot = () => true;\nconst serverSnapshot = () => false;\n/** The viewer's local date (lib/use-today.ts, shared with date-range-picker). Re-exported so existing imports from calendar keep working. */\nexport { useToday } from \"../lib/use-today\";\n\nconst { spring, duration, ease } = motionTokens;\n/** Months sit side by side on one strip. Rapid clicks retarget the same spring, so the strip never queues or stacks panes. */\nconst stripSpring = { type: \"spring\", visualDuration: 0.36, bounce: 0, restDelta: 0.002 } as const;\nconst monthIndex = (date: Date) => date.getFullYear() * 12 + date.getMonth();\nconst fromIndex = (index: number) => new Date(Math.floor(index / 12), ((index % 12) + 12) % 12, 1);\n\n/** The title crossfades in place: the old month leaves toward the side the strip travels to while the new one arrives from the other. */\nconst titleSlide: Variants = {\n  enter: (direction: number) => ({ opacity: 0, x: direction * 12 }),\n  center: { opacity: 1, x: 0, transition: { duration: duration.standard, ease: ease.enter } },\n  exit: (direction: number) => ({ opacity: 0, x: direction * -12, transition: { duration: 0.14, ease: ease.standard } }),\n};\n/** Reduced motion swaps the title in place, with no travel and no blank frame. */\nconst titleFade: Variants = { enter: { opacity: 1, x: 0 }, center: { opacity: 1, x: 0, transition: { duration: 0 } }, exit: { opacity: 0, transition: { duration: 0 } } };\n\n/**\n * The selected disc of one month. It is positioned by week and weekday rather than measured, so it glides on a straight\n * line between any two days, keeps gliding while the strip slides, and retargets mid-flight on a new pick.\n * It only grows in or fades out when the selection enters or leaves this month.\n */\nfunction SelectionDisc({ cell, reduced }: { cell: number; reduced: boolean }) {\n  const visible = cell >= 0;\n  const col = useMotionValue(visible ? cell % 7 : 0);\n  const row = useMotionValue(visible ? Math.floor(cell / 7) : 0);\n  const opacity = useMotionValue(visible ? 1 : 0);\n  const scale = useMotionValue(visible ? 1 : 0.6);\n  // Each step is one cell plus the 4px gap; percentages resolve against the disc's own size, which matches a cell.\n  const transform = useTransform(() => `translate(calc(${col.get()} * (100% + 4px)), calc(${row.get()} * (100% + 4px))) scale(${scale.get()})`);\n  const hidden = useRef(!visible);\n  useLayoutEffect(() => {\n    if (!visible) {\n      hidden.current = true;\n      const fade = reduced ? { duration: 0 } : { duration: 0.14, ease: ease.standard };\n      const controls = [animate(opacity, 0, fade), animate(scale, 0.6, fade)];\n      return () => controls.forEach((control) => control.stop());\n    }\n    const nextCol = cell % 7, nextRow = Math.floor(cell / 7);\n    const controls: AnimationPlaybackControls[] = [];\n    if (hidden.current || reduced) { col.jump(nextCol); row.jump(nextRow); }\n    else controls.push(animate(col, nextCol, spring.morph), animate(row, nextRow, spring.morph));\n    hidden.current = false;\n    controls.push(animate(opacity, 1, reduced ? { duration: 0 } : { duration: duration.fast, ease: ease.enter }), animate(scale, 1, reduced ? { duration: 0 } : spring.snappy));\n    return () => controls.forEach((control) => control.stop());\n  }, [cell, visible, reduced, col, row, opacity, scale]);\n  return <motion.span className={styles.highlight} style={{ transform, opacity }} aria-hidden=\"true\"><span className={styles.highlightFill} /></motion.span>;\n}\n\n/** One month on the strip. Only the month being navigated to is focusable and exposed; the one sliding past is inert. */\nfunction MonthPane({ index, position, present, children }: { index: number; position: MotionValue<number>; present: boolean; children: ReactNode }) {\n  const x = useTransform(position, (value) => `calc(${(index - value) * 100}% + ${(index - value) * 16}px)`);\n  const opacity = useTransform(position, (value) => 1 - Math.min(1, Math.abs(index - value)) * 0.6);\n  return <motion.div className={styles.monthBody} style={{ x, opacity }} data-present={present || undefined} aria-hidden={present ? undefined : true} inert={!present}>{children}</motion.div>;\n}\n\nexport function Calendar({\n  value,\n  onChange,\n  month: controlledMonth,\n  onMonthChange,\n  minDate,\n  maxDate,\n  disabledDates,\n  locale = \"en-US\",\n  className,\n  showToday = false,\n}: CalendarProps) {\n  const titleId = useId();\n  const today = useToday();\n  // Motion preference only counts after hydration, so server and client markup agree.\n  const hydrated = useSyncExternalStore(subscribeNothing, clientSnapshot, serverSnapshot);\n  const reducedMotion = (useReducedMotion() ?? false) && hydrated;\n  const [internalMonth, setInternalMonth] = useState(() => (value ? monthStart(value) : undefined));\n  const [focusedDate, setFocusedDate] = useState(value);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const focusRequest = useRef<string | null>(null);\n  // Without a value or month the calendar waits for the client's today, then settles on that month once.\n  const fallback = value ?? today;\n  const month = controlledMonth ? monthStart(controlledMonth) : internalMonth ?? (fallback && monthStart(fallback));\n  if (!controlledMonth && !internalMonth && month) setInternalMonth(month);\n  const monthTime = month?.getTime();\n  const monthKey = month ? dateKey(month) : \"pending\";\n  // Direction is read from the month itself, so arrows, keys, Today, and a controlled month all travel the same way.\n  const [shownMonth, setShownMonth] = useState(monthTime);\n  const [direction, setDirection] = useState(0);\n  if (shownMonth !== monthTime) {\n    setShownMonth(monthTime);\n    setDirection(shownMonth === undefined || monthTime === undefined ? 0 : monthTime > shownMonth ? 1 : -1);\n  }\n  const weeks = useMemo(() => (monthTime === undefined ? [] : makeWeeks(new Date(monthTime))), [monthTime]);\n  // The strip position is measured in months. The panes on either side of it render, plus the month being navigated to.\n  const target = month ? monthIndex(month) : 0;\n  const position = useMotionValue(target);\n  const [span, setSpan] = useState<[number, number]>([target, target]);\n  useMotionValueEvent(position, \"change\", (value) => {\n    const next: [number, number] = [Math.floor(value + 1e-3), Math.ceil(value - 1e-3)];\n    setSpan((current) => (current[0] === next[0] && current[1] === next[1] ? current : next));\n  });\n  const ready = month !== undefined;\n  const placed = useRef(ready);\n  useLayoutEffect(() => {\n    if (!ready) return;\n    const from = position.get();\n    if (from === target) return;\n    // The first month and reduced motion land in place; everything else slides on the strip.\n    if (!placed.current || reducedMotion) { placed.current = true; position.jump(target); return; }\n    // Long jumps (Today, a year with Shift) start one month away, so the strip never scrolls through the months between.\n    if (Math.abs(target - from) > 2) position.jump(target - Math.sign(target - from));\n    const controls = animate(position, target, stripSpring);\n    return () => controls.stop();\n  }, [ready, target, reducedMotion, position]);\n  const paneIndexes = Array.from(new Set([span[0], span[1], target])).sort((a, b) => a - b);\n  const formatter = useMemo(() => new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }), [locale]);\n  const weekdayFormatter = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\" }), [locale]);\n  const weekdays = useMemo(() => Array.from({ length: 7 }, (_, index) => weekdayFormatter.format(new Date(2024, 0, 7 + index))), [weekdayFormatter]);\n  const monthLabel = month ? formatter.format(month) : \"\";\n  // The selected disc glides between days, so each number flips color as the disc actually passes it:\n  // the day it leaves waits longer on short hops, the day it lands on waits longer on long ones.\n  const valueKey = value ? dateKey(value) : \"\";\n  const [last, setLast] = useState({ key: valueKey, date: value });\n  const [handoff, setHandoff] = useState({ from: \"\", to: \"\", distance: 0 });\n  if (last.key !== valueKey) {\n    const days = last.date && value ? Math.round((startOfDay(value).getTime() - startOfDay(last.date).getTime()) / 864e5) : 0;\n    const columns = last.date && value ? value.getDay() - last.date.getDay() : 0;\n    setLast({ key: valueKey, date: value });\n    setHandoff({ from: last.key, to: valueKey, distance: Math.hypot(columns, (days - columns) / 7) });\n  }\n  const numberDelay = (key: string): CSSProperties | undefined => {\n    if (!handoff.distance || reducedMotion || (key !== handoff.to && key !== handoff.from)) return undefined;\n    const delay = key === handoff.to ? Math.min(260, 95 + 85 * Math.log(handoff.distance)) : Math.max(20, 130 / handoff.distance);\n    return { \"--number-delay\": `${Math.round(delay)}ms` } as CSSProperties;\n  };\n\n  const isDisabled = (date: Date) => isBefore(date, minDate) || isAfter(date, maxDate) || Boolean(disabledDates?.(date));\n  const previousMonth = month && addMonths(month, -1);\n  const nextMonth = month && addMonths(month, 1);\n  // While the month is still unknown the controls keep their resting look, so hydration does not flash them dim.\n  const previousDisabled = Boolean(previousMonth && minDate && previousMonth.getTime() < monthStart(minDate).getTime());\n  const nextDisabled = Boolean(nextMonth && maxDate && nextMonth.getTime() > monthStart(maxDate).getTime());\n  const todaySelectable = Boolean(today && !isDisabled(today) && onChange);\n  const todayIdle = Boolean(today && sameMonth(month, today) && (!todaySelectable || sameDay(value, today)));\n  // Roving tab stop: the day last focused, else the selection, else today, else the first open day of the month.\n  const tabbableKey = (() => {\n    const open = (date?: Date) => (date && sameMonth(date, month) && !isDisabled(date) ? dateKey(date) : \"\");\n    return open(focusedDate) || open(value) || open(today) || dateKey(weeks.flat().find((date) => sameMonth(date, month) && !isDisabled(date)) ?? new Date(0));\n  })();\n\n  const changeMonth = (target: Date) => {\n    const normalized = monthStart(target);\n    if (!controlledMonth) setInternalMonth(normalized);\n    onMonthChange?.(normalized);\n  };\n\n  /** Keyboard focus lands on the new day in the same frame the month starts to slide. */\n  useLayoutEffect(() => {\n    const key = focusRequest.current;\n    if (!key) return;\n    const button = viewportRef.current?.querySelector<HTMLButtonElement>(`[data-present] [data-date=\"${key}\"]`);\n    if (button) { focusRequest.current = null; button.focus({ preventScroll: true }); }\n  });\n\n  /** Clamps a keyboard move to the allowed range and steps past blocked days in the direction of travel. */\n  const moveFocus = (from: Date, target: Date, step: number) => {\n    let next = isBefore(target, minDate) && minDate ? startOfDay(minDate) : isAfter(target, maxDate) && maxDate ? startOfDay(maxDate) : target;\n    for (let tries = 0; tries < 42 && isDisabled(next); tries += 1) next = addDays(next, step);\n    if (isDisabled(next) || sameDay(next, from)) return;\n    setFocusedDate(next);\n    focusRequest.current = dateKey(next);\n    if (!sameMonth(next, month)) changeMonth(next);\n  };\n\n  const onDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, date: Date) => {\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (!isDisabled(date)) onChange?.(date);\n      return;\n    }\n    const moves: Record<string, [Date, number]> = {\n      ArrowLeft: [addDays(date, -1), -1],\n      ArrowRight: [addDays(date, 1), 1],\n      ArrowUp: [addDays(date, -7), -1],\n      ArrowDown: [addDays(date, 7), 1],\n      Home: [addDays(date, -date.getDay()), 1],\n      End: [addDays(date, 6 - date.getDay()), -1],\n      PageUp: [shiftMonths(date, event.shiftKey ? -12 : -1), -1],\n      PageDown: [shiftMonths(date, event.shiftKey ? 12 : 1), 1],\n    };\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    moveFocus(date, move[0], move[1]);\n  };\n\n  const goToToday = () => {\n    if (!today || !month || todayIdle) return;\n    setFocusedDate(today);\n    if (!sameMonth(today, month)) changeMonth(today);\n    if (todaySelectable && !sameDay(value, today)) onChange?.(today);\n  };\n\n  const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(\" \");\n  // Six fixed weeks per month, so a day's cell is its distance from the first day on the grid.\n  const cellOf = (paneMonth: Date) => {\n    if (!value) return -1;\n    const first = addDays(paneMonth, -paneMonth.getDay());\n    const index = Math.round((startOfDay(value).getTime() - first.getTime()) / 864e5);\n    return index >= 0 && index < 42 ? index : -1;\n  };\n\n  const renderMonth = (paneMonth: Date, present: boolean) => <>\n    <SelectionDisc cell={cellOf(paneMonth)} reduced={reducedMotion} />\n    <div className={styles.grid} role=\"grid\" aria-label={formatter.format(paneMonth)}>\n    {makeWeeks(paneMonth).map((week) => <div key={dateKey(week[0]!)} className={styles.week} role=\"row\">\n      {week.map((date) => {\n        const key = dateKey(date);\n        const selected = sameDay(date, value);\n        const isToday = sameDay(date, today);\n        return <button\n          key={key}\n          type=\"button\"\n          role=\"gridcell\"\n          data-date={key}\n          aria-label={date.toLocaleDateString(locale, { dateStyle: \"full\" })}\n          aria-selected={selected}\n          aria-current={isToday ? \"date\" : undefined}\n          tabIndex={present && key === tabbableKey ? 0 : -1}\n          disabled={isDisabled(date)}\n          style={present ? numberDelay(key) : undefined}\n          className={cx(styles.day, !sameMonth(date, paneMonth) && styles.outside, selected && styles.selected, isToday && styles.today)}\n          onFocus={() => setFocusedDate(date)}\n          onKeyDown={(event) => onDayKeyDown(event, date)}\n          onClick={() => onChange?.(date)}\n        ><span className={styles.dayNumber}>{date.getDate()}</span></button>;\n      })}\n    </div>)}\n    </div>\n  </>;\n\n  return <section className={cx(styles.calendar, className)} aria-labelledby={titleId}>\n      <div className={styles.header}>\n        <h2 id={titleId} className={styles.heading}>\n          <span className={styles.srOnly}>{monthLabel}</span>\n          <span className={styles.title} aria-hidden=\"true\">\n            <AnimatePresence initial={false} custom={direction}>\n              {month && <motion.span key={monthKey} className={styles.titleRow} custom={direction} variants={reducedMotion || !direction ? titleFade : titleSlide} initial=\"enter\" animate=\"center\" exit=\"exit\">{monthLabel}</motion.span>}\n            </AnimatePresence>\n          </span>\n        </h2>\n        <span className={styles.srOnly} aria-live=\"polite\">{direction ? monthLabel : \"\"}</span>\n        <div className={styles.navigation}>\n          {showToday && <button type=\"button\" className={styles.todayButton} aria-disabled={todayIdle || undefined} aria-label={today ? `Today, ${today.toLocaleDateString(locale, { dateStyle: \"full\" })}` : \"Today\"} onClick={goToToday}>Today</button>}\n          <button type=\"button\" className={styles.navButton} aria-label=\"Previous month\" aria-disabled={previousDisabled || undefined} onClick={() => { if (!previousDisabled && previousMonth) changeMonth(previousMonth); }}><ChevronLeft size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n          <button type=\"button\" className={styles.navButton} aria-label=\"Next month\" aria-disabled={nextDisabled || undefined} onClick={() => { if (!nextDisabled && nextMonth) changeMonth(nextMonth); }}><ChevronRight size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n        </div>\n      </div>\n      <div className={styles.weekdays} aria-hidden=\"true\">{weekdays.map((day, index) => <span key={`${day}-${index}`}>{day.slice(0, 2)}</span>)}</div>\n      <div className={styles.monthViewport} ref={viewportRef}>\n        {month ? paneIndexes.map((index) => <MonthPane key={index} index={index} position={position} present={index === target}>{renderMonth(fromIndex(index), index === target)}</MonthPane>)\n          : <div className={styles.monthBody} aria-hidden=\"true\"><div className={styles.grid}>{Array.from({ length: 6 }, (_, week) => <div key={week} className={styles.week}>{Array.from({ length: 7 }, (_, day) => <span key={day} className={styles.placeholderDay} />)}</div>)}</div></div>}\n      </div>\n    </section>;\n}\n\nexport { addDays, addMonths, sameDay, startOfDay };\n"
    },
    {
      "path": "lib/use-today.ts",
      "type": "registry:lib",
      "target": "@components/xzero/lib/use-today.ts",
      "content": "import { useMemo, useSyncExternalStore } from \"react\";\n\n/** Shared by calendar and date-range-picker, which both re-export it, so one copy keeps their \"today\" in step. */\nconst dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, \"0\")}-${String(date.getDate()).padStart(2, \"0\")}`;\nconst fromKey = (key: string) => {\n  const [year = 1970, month = 1, day = 1] = key.split(\"-\").map(Number);\n  return new Date(year, month - 1, day);\n};\n\n/** Today turns over at local midnight; returning to the tab, focusing the window or waking the device reads it again. */\nconst subscribeToday = (notify: () => void) => {\n  let timer = 0;\n  const schedule = () => {\n    const now = new Date();\n    const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n    timer = window.setTimeout(() => { notify(); schedule(); }, nextMidnight.getTime() - now.getTime() + 1000);\n  };\n  const onVisible = () => { if (document.visibilityState === \"visible\") notify(); };\n  schedule();\n  document.addEventListener(\"visibilitychange\", onVisible);\n  window.addEventListener(\"focus\", notify);\n  return () => { window.clearTimeout(timer); document.removeEventListener(\"visibilitychange\", onVisible); window.removeEventListener(\"focus\", notify); };\n};\nconst readToday = () => dateKey(new Date());\nconst serverToday = () => \"\";\n\n/** The viewer's local date. Undefined on the server and during hydration, so markup never depends on the server clock or time zone; afterwards it follows the real date across midnight. */\nexport function useToday() {\n  const key = useSyncExternalStore(subscribeToday, readToday, serverToday);\n  return useMemo(() => (key ? fromKey(key) : undefined), [key]);\n}\n"
    },
    {
      "path": "registry/components/calendar/calendar.module.css",
      "type": "registry:component",
      "target": "@components/xzero/calendar/calendar.module.css",
      "content": ".calendar { width: min(100%, 328px); min-width: 0; color: var(--foreground); }\n/* The header is its own size container: below 260px the title takes a full row and the controls sit under it, decided by width alone so a long month name never reflows it. */\n.header { container-type: inline-size; display: grid; min-height: 34px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; margin-bottom: 14px; }\n@container (max-width: 259px) { .heading, .navigation { grid-column: 1 / -1; } .navigation { justify-self: end; } }\n.heading { min-width: 0; margin: 0; font-family: var(--font-body); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); }\n/* The global heading rule forces a tight line-height on h2, so the title row sets its own to center with the controls. */\n.title { display: grid; line-height: var(--leading-body); }\n/* Old and new titles share one cell while they cross, so the heading never changes height or jumps. */\n.title > .titleRow { grid-area: 1 / 1; justify-self: start; }\n.titleRow { display: inline-block; white-space: nowrap; font-variant-numeric: tabular-nums; }\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.navigation { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 2px; margin-right: -6px; }\n/* Quiet header controls: a fill on hover, a short press, and a spring release. */\n.navButton, .todayButton { display: grid; height: 32px; place-items: center; border: 0; border-radius: var(--radius-pill); 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), opacity var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }\n.navButton { width: 32px; }\n.todayButton { padding: 0 11px; font-size: var(--text-sm); font-weight: 500; line-height: 1; }\n@media (hover: hover) and (pointer: fine) { .navButton:hover:not([aria-disabled]), .todayButton:hover:not([aria-disabled]) { background: var(--surface-muted); color: var(--foreground); } }\n.navButton:active:not([aria-disabled]), .todayButton:active:not([aria-disabled]) { scale: .96; transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard); }\n.navButton[aria-disabled], .todayButton[aria-disabled] { cursor: default; opacity: .36; }\n.weekdays, .week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); column-gap: 4px; }\n.weekdays { margin-bottom: 6px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: 24px; text-align: center; }\n.weekdays span { min-width: 0; overflow: hidden; }\n/* Months sit on one strip inside a clipped window. Every pane shares one grid cell and has six weeks, so the height never changes. */\n.monthViewport { position: relative; display: grid; overflow: hidden; contain: paint; }\n.monthBody { position: relative; grid-area: 1 / 1; min-width: 0; will-change: transform; }\n.monthBody[inert] { pointer-events: none; }\n.grid { display: grid; row-gap: 4px; }\n.placeholderDay { aspect-ratio: 1; }\n.day { position: relative; display: grid; width: 100%; aspect-ratio: 1; min-width: 0; place-items: center; border: 0; border-radius: 50%; padding: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 400; line-height: 1; font-variant-numeric: tabular-nums; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard) var(--number-delay, 0ms); }\n/* The inner circle carries hover and press; the grid cell itself never moves, so the hit area stays put. */\n.day::before { content: \"\"; position: absolute; inset: 0; border-radius: inherit; background: transparent; transition: background-color var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }\n.dayNumber { position: relative; transition: opacity var(--duration-fast) var(--ease-standard) var(--number-delay, 0ms), scale var(--duration-spring) var(--ease-spring); }\n/* The selected disc is one element per month, one cell in size, placed by week and weekday under the day buttons. It glides on a transform, so the strip sliding around it never bends its path. Press scales the inner fill so it never fights the glide. */\n.highlight { position: absolute; top: 0; left: 0; width: calc((100% - 24px) / 7); aspect-ratio: 1; pointer-events: none; will-change: transform; }\n.highlightFill { position: absolute; inset: 0; border-radius: 50%; background: var(--accent); transition: background-color var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .day:hover:not(:disabled):not(.selected)::before { background: var(--surface-muted); } .monthBody:has(.day.selected:hover) .highlightFill { background: var(--accent-strong); } }\n.day:active:not(:disabled)::before, .day:active:not(:disabled) .dayNumber, .monthBody:has(.day.selected:active:not(:disabled)) .highlightFill { scale: .94; transition-duration: var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard); }\n/* Outside days dim their number, not the button, so a disc gliding onto one keeps its full strength. */\n.day.outside { color: var(--text-muted); }\n.day.outside:not(.selected) .dayNumber { opacity: .8; }\n/* Today is a small dot under the number. It takes the number's color, so it flips with it when the disc arrives. */\n.day.today .dayNumber::after { content: \"\"; position: absolute; top: calc(100% + 4px); left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; }\n.day.selected, .day.today { font-weight: 500; }\n.day.selected { color: var(--accent-foreground); }\n.day:disabled { cursor: default; color: var(--text-muted); }\n.day:disabled .dayNumber { opacity: .42; }\n.day.outside:disabled .dayNumber { opacity: .3; }\n@media (prefers-reduced-motion: reduce) {\n  .navButton, .todayButton, .day, .day::before, .dayNumber, .highlightFill { transition: none; }\n  .navButton:active, .todayButton:active, .day:active::before, .monthBody:has(.day.selected:active) .highlightFill, .day:active .dayNumber { scale: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/calendar",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "date",
      "field"
    ],
    "docs": "https://ui.xzero.co.uk/components/calendar",
    "markdown": "https://ui.xzero.co.uk/components/calendar/markdown"
  }
}