{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "date-picker",
  "type": "registry:block",
  "title": "Date Picker",
  "description": "An inline calendar for a single date or a range, with months that slide on an interruptible spring and a rounded band that previews to the hovered date, then fills in a wave on commit. Full ARIA grid keyboard support, blocked and past dates, presets and a two-month view.",
  "docs": "Date Picker was added to public/vividsites/date-picker.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/date-picker",
  "categories": [
    "ui",
    "forms"
  ],
  "files": [
    {
      "path": "components/vividsites/date-picker.html",
      "type": "registry:file",
      "target": "public/vividsites/date-picker.html",
      "content": "<!--\nvs-date-picker: an inline calendar for one date or a range. Months ride a real spring, so\n  paging is direction-aware and interruptible (click fast and it keeps its velocity; swipe\n  it on touch). In range mode a rounded band previews from the start date to wherever you\n  hover, then fills in a wave from start to end when you commit. The band joins cells and\n  caps itself at row ends and month edges. Past, blocked and out-of-reach dates are\n  disabled, and once a start is picked, dates past the next blocked one grey out.\n\n  Markup:  <div class=\"vs-date-picker\" data-mode=\"range\" data-months=\"2\"></div>\n  Attributes (all optional):\n    data-mode        \"range\" (default) or \"single\"\n    data-months      1 or 2. With 2, it drops to 1 by itself when its parent is too narrow\n    data-value       \"2026-10-09/2026-10-13\" (range) or \"2026-10-09\" (single)\n    data-min         \"today\" (default), an ISO date, or \"none\"\n    data-max         an ISO date\n    data-blocked     comma list of ISO dates that can't be picked (a range can't cross them)\n    data-presets     \"weekend,week,fortnight,month\", or \"key:Label\" pairs to rename them\n    data-count       \"nights\" (default), \"days\" or \"none\": the length badge in the footer\n    data-week-start  0 = Sunday (default), 1 = Monday\n    data-locale      BCP 47 tag for month and weekday names (default: the page's)\n    data-name        name of a hidden input for forms; value \"start/end\" (ISO 8601 interval)\n    data-today       override \"today\" (ISO), handy for demos and tests\n    data-label       accessible name of the whole picker (default \"Choose dates\")\n  Keyboard (on the grid, APG date grid): arrows move a day / week, Home / End go to the\n    start / end of the week, PageUp / PageDown change month (Shift: year), Enter or Space\n    picks. The grid uses a roving tabindex: Tab enters it once, on the focused date.\n  Variables: --vs-date-picker-size (scales everything), -cell, -accent, -on-accent, -band,\n    -surface, -ink, -mute, -line, -radius, -font, -title-font, -stiffness, -damping.\n  Events: \"vs-date-change\" bubbles with detail {start, end, value} (ISO strings or null).\n  Methods on the element: el.vsSet(\"2026-10-09\", \"2026-10-13\"), el.vsClear(), el.vsGoTo(\"2026-12-01\").\n  Reduced motion: months swap without sliding, no pops or waves.\n-->\n<style>\n.vs-date-picker {\n  --vs-date-picker-size: 16px;                 /* scales the whole calendar */\n  --vs-date-picker-cell: 2.75em;               /* height of a day cell */\n  --vs-date-picker-accent: #1f5f45;            /* endpoints, today, focus */\n  --vs-date-picker-on-accent: #ffffff;         /* digits on the accent */\n  --vs-date-picker-surface: #fcfaf5;           /* the card */\n  --vs-date-picker-band: color-mix(in srgb, var(--vs-date-picker-accent) 15%, var(--vs-date-picker-surface));\n  --vs-date-picker-ink: #1c2420;\n  --vs-date-picker-mute: color-mix(in srgb, var(--vs-date-picker-ink) 46%, transparent);\n  --vs-date-picker-line: color-mix(in srgb, var(--vs-date-picker-ink) 11%, transparent);\n  --vs-date-picker-radius: 1.5em;\n  --vs-date-picker-font: inherit;\n  --vs-date-picker-title-font: inherit;\n  --vs-date-picker-stiffness: 230;             /* month spring */\n  --vs-date-picker-damping: 25;\n  --vs-date-picker-gap: 2.25em;                /* between the two months */\n  --vs-date-picker-pad: 1.5em;\n\n  --m: 1;                                      /* months shown, written by the script */\n  --vs-date-picker-pop: linear(0, .046, .165, .328, .509, .688, .85, .985, 1.087, 1.156, 1.194, 1.205, 1.196, 1.171, 1.138, 1.101, 1.064, 1.031, 1.003, .982, .968, .96, .958, .96, .965, .972, .979, .987, .994, .999, 1);\n  position: relative; display: inline-block; vertical-align: top; box-sizing: border-box;\n  width: min(100%, calc(var(--m) * 7 * var(--vs-date-picker-cell) + (var(--m) - 1) * var(--vs-date-picker-gap) + 2 * var(--vs-date-picker-pad)));\n  padding: var(--vs-date-picker-pad);\n  font-size: var(--vs-date-picker-size); font-family: var(--vs-date-picker-font); font-weight: 400; line-height: 1.2;\n  color: var(--vs-date-picker-ink); background: var(--vs-date-picker-surface);\n  border-radius: var(--vs-date-picker-radius);\n  box-shadow: 0 0 0 .5px color-mix(in srgb, var(--vs-date-picker-ink) 12%, transparent), inset 0 1px 0 rgba(255,255,255,.9),\n              0 1px 2px rgba(40,30,10,.05), 0 .75em 2em -1em rgba(40,30,10,.16), 0 2.5em 4em -2em rgba(40,30,10,.14);\n  -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  font-variant-numeric: tabular-nums;\n}\n.vs-date-picker *, .vs-date-picker *::before, .vs-date-picker *::after { box-sizing: border-box; }\n:where(.vs-date-picker) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n\n/* month arrows */\n.vs-date-picker-arrow {\n  position: absolute; z-index: 2; top: calc(var(--vs-date-picker-pad) + .1em); width: 2.25em; height: 2.25em; border-radius: 50%;\n  display: grid; place-items: center; color: var(--vs-date-picker-ink);\n  transition: background-color .2s cubic-bezier(.2,0,0,1), transform .35s var(--vs-date-picker-pop), opacity .2s;\n}\n.vs-date-picker-arrow[data-dir=\"-1\"] { left: calc(var(--vs-date-picker-pad) - .5em); }\n.vs-date-picker-arrow[data-dir=\"1\"] { right: calc(var(--vs-date-picker-pad) - .5em); }\n.vs-date-picker-arrow svg { width: 1em; height: 1em; }\n.vs-date-picker-arrow:hover { background: color-mix(in srgb, var(--vs-date-picker-ink) 6%, transparent); }\n.vs-date-picker-arrow:active { transform: scale(.88); transition-duration: .08s; }\n.vs-date-picker-arrow:disabled { opacity: .25; cursor: default; background: none; transform: none; }\n.vs-date-picker-arrow:focus-visible, .vs-date-picker-chip:focus-visible, .vs-date-picker-clear:focus-visible {\n  outline: none; box-shadow: 0 0 0 2px var(--vs-date-picker-surface), 0 0 0 3.5px var(--vs-date-picker-accent);\n}\n\n/* sliding months */\n.vs-date-picker-viewport {\n  position: relative; overflow: hidden; margin: 0 calc(-1 * var(--vs-date-picker-pad));\n  /* 1.25em of extra room below the last row (cancelled by the negative margin) so the\n     selected circles' shadows are never cut by the clip */\n  height: calc(2.75em + 1.7em + var(--rows, 6) * var(--vs-date-picker-cell) + 1.25em);\n  padding-bottom: 1.25em; margin-bottom: -1.25em;\n  transition: height .5s cubic-bezier(.16,1,.3,1);\n  /* two mask layers: the title strip fades out wider, clear of the arrow buttons, so a sliding\n     month title never runs under them; the grid below fades only at the card edges */\n  --vs-date-picker-fade: linear-gradient(90deg, transparent, #000 var(--vs-date-picker-pad), #000 calc(100% - var(--vs-date-picker-pad)), transparent);\n  --vs-date-picker-fade-t: linear-gradient(90deg, transparent calc(var(--vs-date-picker-pad) + 1.9em), #000 calc(var(--vs-date-picker-pad) + 3.4em), #000 calc(100% - var(--vs-date-picker-pad) - 3.4em), transparent calc(100% - var(--vs-date-picker-pad) - 1.9em));\n  -webkit-mask: var(--vs-date-picker-fade-t) top / 100% 2.75em no-repeat, var(--vs-date-picker-fade) bottom / 100% calc(100% - 2.75em) no-repeat;\n          mask: var(--vs-date-picker-fade-t) top / 100% 2.75em no-repeat, var(--vs-date-picker-fade) bottom / 100% calc(100% - 2.75em) no-repeat;\n  touch-action: pan-y;\n}\n.vs-date-picker-month { position: absolute; top: 0; left: var(--vs-date-picker-pad); will-change: transform, opacity; }\n.vs-date-picker-title {\n  height: 2.75em; display: flex; align-items: center; justify-content: center; gap: .32em; padding-bottom: .3em;\n  font-family: var(--vs-date-picker-title-font); font-weight: 400; letter-spacing: -.012em;\n  font-variation-settings: \"opsz\" 40; white-space: nowrap;\n}\n.vs-date-picker-title span { font-size: 1.28em; }\n.vs-date-picker-title span + span { color: var(--vs-date-picker-mute); }\n.vs-date-picker-grid { outline: none; }\n.vs-date-picker-wk, .vs-date-picker-row { display: grid; grid-template-columns: repeat(7, 1fr); }\n.vs-date-picker-wk { height: 1.7em; align-items: start; }\n.vs-date-picker-wk > * { text-align: center; font-size: .72em; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--vs-date-picker-mute); }\n.vs-date-picker-row { height: var(--vs-date-picker-cell); }\n\n/* a day: the cell carries the band (::before); the number is the circle */\n.vs-date-picker-day { position: relative; display: grid; place-items: center; outline: none; cursor: pointer; }\n.vs-date-picker-day.is-blank { cursor: default; }\n.vs-date-picker-day::before {\n  content: \"\"; position: absolute; top: .16em; bottom: .16em; left: 0; right: 0;\n  background: var(--vs-date-picker-band); opacity: 0; pointer-events: none;\n  transition: opacity .16s cubic-bezier(.2,0,0,1), background-color .3s cubic-bezier(.2,0,0,1);\n}\n.vs-date-picker.is-wave .vs-date-picker-day::before { transition-delay: calc(min(var(--i, 0), 24) * 16ms); }\n.vs-date-picker-day.is-in::before { opacity: 1; }\n.vs-date-picker-day.is-s::before { left: 50%; }\n.vs-date-picker-day.is-e::before { right: 50%; }\n.vs-date-picker-row > .vs-date-picker-day:first-child::before, .vs-date-picker-day.is-first::before { left: .16em; border-radius: 999px 0 0 999px; }\n.vs-date-picker-row > .vs-date-picker-day:last-child::before, .vs-date-picker-day.is-last::before { right: .16em; border-radius: 0 999px 999px 0; }\n.vs-date-picker-row > .vs-date-picker-day:first-child.is-s::before, .vs-date-picker-day.is-first.is-s::before { left: 50%; border-radius: 0; }\n.vs-date-picker-row > .vs-date-picker-day:last-child.is-e::before, .vs-date-picker-day.is-last.is-e::before { right: 50%; border-radius: 0; }\n.vs-date-picker-row > .vs-date-picker-day:first-child.is-last::before, .vs-date-picker-row > .vs-date-picker-day:last-child.is-first::before,\n.vs-date-picker-day.is-first.is-last::before { border-radius: 999px; }\n/* preview (hovering an end) is a lighter, softer band */\n.vs-date-picker.is-preview .vs-date-picker-day::before { background: color-mix(in srgb, var(--vs-date-picker-accent) 8%, var(--vs-date-picker-surface)); }\n\n.vs-date-picker-num {\n  position: relative; z-index: 1; height: calc(var(--vs-date-picker-cell) - .32em); aspect-ratio: 1; max-width: 100%;\n  display: grid; place-items: center; border-radius: 50%; font-size: 1em; font-weight: 500;\n  transition: background-color .18s cubic-bezier(.2,0,0,1), color .18s, box-shadow .18s, transform .3s var(--vs-date-picker-pop), opacity .2s;\n}\n.vs-date-picker-day:not(.is-off):not(.is-blank):hover .vs-date-picker-num { background: color-mix(in srgb, var(--vs-date-picker-accent) 9%, transparent); }\n.vs-date-picker-day:not(.is-off):not(.is-blank):active .vs-date-picker-num { transform: scale(.88); transition-duration: .1s; }\n.vs-date-picker-day.is-pe .vs-date-picker-num { box-shadow: inset 0 0 0 1.5px var(--vs-date-picker-accent); background: var(--vs-date-picker-surface) !important; }\n.vs-date-picker-day.is-sel .vs-date-picker-num {\n  background: var(--vs-date-picker-accent) !important; color: var(--vs-date-picker-on-accent);\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 .1em .3em -.05em color-mix(in srgb, var(--vs-date-picker-accent) 60%, transparent), 0 .5em 1em -.5em color-mix(in srgb, var(--vs-date-picker-accent) 80%, transparent);\n  animation: vs-date-picker-pop .6s var(--vs-date-picker-pop);\n}\n@keyframes vs-date-picker-pop { from { transform: scale(.55); } }\n.vs-date-picker-day:focus-visible .vs-date-picker-num { box-shadow: 0 0 0 2px var(--vs-date-picker-surface), 0 0 0 3.5px var(--vs-date-picker-accent); }\n.vs-date-picker-day.is-sel:focus-visible .vs-date-picker-num { box-shadow: 0 0 0 2px var(--vs-date-picker-surface), 0 0 0 3.5px var(--vs-date-picker-ink); }\n.vs-date-picker-day.is-today .vs-date-picker-num::after {\n  content: \"\"; position: absolute; left: 50%; bottom: 17%; width: .25em; height: .25em; margin-left: -.125em; border-radius: 50%;\n  background: var(--vs-date-picker-accent);\n}\n.vs-date-picker-day.is-today.is-sel .vs-date-picker-num::after { background: var(--vs-date-picker-on-accent); }\n.vs-date-picker-day.is-off { cursor: default; }\n.vs-date-picker-day.is-off .vs-date-picker-num { color: color-mix(in srgb, var(--vs-date-picker-ink) 26%, transparent); font-weight: 400; }\n.vs-date-picker-day.is-blocked .vs-date-picker-num { text-decoration: line-through; text-decoration-thickness: .08em; text-decoration-color: color-mix(in srgb, var(--vs-date-picker-ink) 34%, transparent); }\n.vs-date-picker-day.is-far .vs-date-picker-num { opacity: .3; }\n\n/* footer: presets, summary, clear */\n.vs-date-picker-foot {\n  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75em 1.25em;\n  margin-top: .5em; padding-top: 1em; box-shadow: inset 0 .5px 0 var(--vs-date-picker-line);\n}\n.vs-date-picker-presets { display: flex; gap: .4em; min-width: 0; overflow-x: auto; scrollbar-width: none; margin: -.25em; padding: .25em; }\n.vs-date-picker-presets::-webkit-scrollbar { display: none; }\n.vs-date-picker-presets:empty { display: none; }\n.vs-date-picker-chip {\n  flex: none; height: 2.2em; padding: 0 .95em; border-radius: 999px; font-size: .84em !important; font-weight: 500 !important; white-space: nowrap;\n  box-shadow: inset 0 0 0 1px var(--vs-date-picker-line);\n  transition: background-color .18s cubic-bezier(.2,0,0,1), box-shadow .18s, color .18s, transform .35s var(--vs-date-picker-pop);\n}\n.vs-date-picker-chip:hover { background: color-mix(in srgb, var(--vs-date-picker-ink) 4.5%, transparent); }\n.vs-date-picker-chip:active { transform: scale(.94); transition-duration: .08s; }\n.vs-date-picker-chip[aria-pressed=\"true\"] { color: var(--vs-date-picker-accent); background: color-mix(in srgb, var(--vs-date-picker-accent) 9%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-date-picker-accent) 45%, transparent); }\n.vs-date-picker-chip:disabled { opacity: .38; cursor: not-allowed; background: none; transform: none; }\n.vs-date-picker-sum { display: flex; align-items: center; gap: .7em; margin-left: auto; min-height: 2.2em; font-size: .9em; }\n.vs-date-picker-sum-t { white-space: nowrap; }\n.vs-date-picker-sum-t.is-new { animation: vs-date-picker-rise .4s cubic-bezier(.16,1,.3,1); }\n@keyframes vs-date-picker-rise { from { opacity: 0; transform: translateY(.35em); } }\n.vs-date-picker-sum-t i { font-style: normal; color: var(--vs-date-picker-mute); padding: 0 .3em; }\n.vs-date-picker-sum-t em { font-style: normal; color: var(--vs-date-picker-mute); }\n.vs-date-picker-badge {\n  font-size: .86em; font-weight: 500; padding: .3em .65em; border-radius: 999px; white-space: nowrap;\n  color: var(--vs-date-picker-accent); background: color-mix(in srgb, var(--vs-date-picker-accent) 11%, transparent);\n}\n.vs-date-picker-badge:empty { display: none; }\n.vs-date-picker-clear {\n  font-weight: 500 !important; height: 2em; padding: 0 .35em !important; border-radius: .5em;\n  text-decoration: underline; text-decoration-color: var(--vs-date-picker-line); text-underline-offset: .25em; transition: opacity .2s, text-decoration-color .2s;\n}\n.vs-date-picker-clear:hover { text-decoration-color: currentColor; }\n.vs-date-picker-clear[hidden] { display: inline-block; visibility: hidden; }\n.vs-date-picker-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .vs-date-picker *, .vs-date-picker *::before { animation: none !important; transition-duration: .01ms !important; transition-delay: 0s !important; }\n}\n@media (forced-colors: active) {\n  .vs-date-picker-day.is-in::before { background: Highlight; opacity: .35; }\n  .vs-date-picker-day.is-sel .vs-date-picker-num { background: Highlight !important; color: HighlightText; }\n}\n</style>\n\n<div class=\"vs-date-picker\" data-mode=\"range\" data-months=\"2\" data-presets=\"weekend,week,fortnight\" data-name=\"stay\"></div>\n\n<script>\n(() => {\n  if (window.vsDatePicker) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const DAY = 864e5;\n  // dates are whole UTC day numbers: no time zones, no DST surprises\n  const fromISO = (s) => { const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec((s || '').trim()); return m ? Date.UTC(+m[1], m[2] - 1, +m[3]) / DAY : null; };\n  const toISO = (n) => new Date(n * DAY).toISOString().slice(0, 10);\n  const parts = (n) => { const d = new Date(n * DAY); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()]; };\n  const monthOf = (n) => { const [y, m] = parts(n); return y * 12 + m; };\n  const mStart = (mi) => Date.UTC(Math.floor(mi / 12), mi % 12, 1) / DAY;\n  const mLen = (mi) => mStart(mi + 1) - mStart(mi);\n  const dow = (n) => new Date(n * DAY).getUTCDay();\n  const addMonths = (n, k) => { const [, , d] = parts(n); const mi = monthOf(n) + k; return mStart(mi) + Math.min(d, mLen(mi)) - 1; };\n  const localToday = () => { const t = new Date(); return Date.UTC(t.getFullYear(), t.getMonth(), t.getDate()) / DAY; };\n  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\n  // one shared rAF loop, running only while a picker is moving\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = (now) => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const p of live) if (!p.update(dt)) live.delete(p);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = (p) => { live.add(p); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n\n  const PRESETS = { weekend: 'This weekend', week: 'Next 7 days', fortnight: 'Next 14 days', month: 'Next month' };\n  const CHEV = (d) => `<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"${d}\"/></svg>`;\n  let uid = 0;\n\n  function mount(root) {\n    if (root.__vsDp) return root.__vsDp;\n    const id = 'vsdp' + (++uid);\n    const o = {};\n    const read = () => {\n      const a = root.dataset;\n      o.mode = a.mode === 'single' ? 'single' : 'range';\n      o.months = a.months === '2' ? 2 : 1;\n      o.ws = a.weekStart === '1' ? 1 : 0;\n      o.locale = a.locale || document.documentElement.lang || undefined;\n      o.today = fromISO(a.today) ?? localToday();\n      o.min = a.min === 'none' ? -Infinity : (fromISO(a.min) ?? o.today);\n      o.max = fromISO(a.max) ?? Infinity;\n      o.blocked = new Set((a.blocked || '').split(',').map(fromISO).filter((n) => n != null));\n      o.count = a.count || 'nights';\n      o.presets = (a.presets || '').split(',').map((s) => s.trim()).filter(Boolean).map((s) => { const [k, l] = s.split(':'); return { k: k.trim(), l: (l || PRESETS[k.trim()] || k).trim() }; });\n    };\n    read();\n    const fmt = (opt) => new Intl.DateTimeFormat(o.locale, { timeZone: 'UTC', ...opt });\n    const fMonth = fmt({ month: 'long' }), fShort = fmt({ weekday: 'short', month: 'short', day: 'numeric' }),\n          fLong = fmt({ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }), fWd = fmt({ weekday: 'short' }), fWdL = fmt({ weekday: 'long' });\n    const off = (n) => n < o.min || n > o.max || o.blocked.has(n);\n\n    // state\n    let start = null, end = null, hover = null, kbd = false;\n    const v0 = (root.dataset.value || '').split('/');\n    start = fromISO(v0[0]); end = o.mode === 'range' ? fromISO(v0[1]) : null;\n    if (start != null && end != null && end <= start) end = null;\n    let focus = start ?? clamp(o.today, o.min, o.max);\n    const vm0 = monthOf(start ?? clamp(o.today, o.min, o.max === Infinity ? o.today : o.max));\n    const view = { x: vm0, v: 0, g: vm0 };\n    let months = o.months, paneW = 0, stride = 0, drag = null, suppressClick = false;\n\n    root.setAttribute('role', 'group');\n    if (!root.hasAttribute('aria-label')) root.setAttribute('aria-label', root.dataset.label || 'Choose dates');\n    root.innerHTML =\n      `<button type=\"button\" class=\"vs-date-picker-arrow\" data-dir=\"-1\" aria-label=\"Previous month\">${CHEV('M10 3.5 5.5 8l4.5 4.5')}</button>` +\n      `<button type=\"button\" class=\"vs-date-picker-arrow\" data-dir=\"1\" aria-label=\"Next month\">${CHEV('M6 3.5 10.5 8 6 12.5')}</button>` +\n      `<div class=\"vs-date-picker-viewport\"></div>` +\n      `<div class=\"vs-date-picker-foot\"><div class=\"vs-date-picker-presets\" role=\"group\" aria-label=\"Quick ranges\"></div>` +\n      `<div class=\"vs-date-picker-sum\"><span class=\"vs-date-picker-sum-t\" aria-live=\"polite\"></span><span class=\"vs-date-picker-badge\"></span>` +\n      `<button type=\"button\" class=\"vs-date-picker-clear\">Clear</button></div></div>`;\n    const vp = root.querySelector('.vs-date-picker-viewport');\n    const [prevB, nextB] = root.querySelectorAll('.vs-date-picker-arrow');\n    const presetsEl = root.querySelector('.vs-date-picker-presets');\n    const sumT = root.querySelector('.vs-date-picker-sum-t'), badge = root.querySelector('.vs-date-picker-badge'), clearB = root.querySelector('.vs-date-picker-clear');\n    let input = null;\n    if (root.dataset.name) { input = document.createElement('input'); input.type = 'hidden'; input.name = root.dataset.name; root.appendChild(input); }\n\n    // ---- month panes (created for the months in or entering view, dropped after) ----\n    const panes = new Map(); let lastRows = 0;\n    const weekdays = () => {\n      let h = ''; const sun = 3; // 1970-01-04 was a Sunday (day 3)\n      for (let i = 0; i < 7; i++) { const n = sun + ((i + o.ws) % 7); h += `<span role=\"columnheader\" aria-label=\"${fWdL.format(n * DAY)}\">${fWd.format(n * DAY).replace('.', '').slice(0, 2)}</span>`; }\n      return h;\n    };\n    const makePane = (mi) => {\n      const el = document.createElement('div'); el.className = 'vs-date-picker-month';\n      const s = mStart(mi), len = mLen(mi), lead = (dow(s) - o.ws + 7) % 7, tid = `${id}-${mi}`;\n      let rows = '';\n      for (let r = 0; r < 6; r++) {\n        let cells = '', any = false;\n        for (let c = 0; c < 7; c++) {\n          const d = r * 7 + c - lead + 1;\n          if (d < 1 || d > len) { cells += '<span role=\"gridcell\" class=\"vs-date-picker-day is-blank\"></span>'; continue; }\n          any = true; const n = s + d - 1, x = off(n);\n          const cls = ['vs-date-picker-day'];\n          if (d === 1) cls.push('is-first'); if (d === len) cls.push('is-last');\n          if (n === o.today) cls.push('is-today'); if (x) cls.push('is-off'); if (o.blocked.has(n)) cls.push('is-blocked');\n          cells += `<span role=\"gridcell\" class=\"${cls.join(' ')}\" data-n=\"${n}\" tabindex=\"-1\" aria-selected=\"false\"${x ? ' aria-disabled=\"true\"' : ''}${n === o.today ? ' aria-current=\"date\"' : ''} aria-label=\"${fLong.format(n * DAY)}${o.blocked.has(n) ? ', unavailable' : ''}\"><span class=\"vs-date-picker-num\" aria-hidden=\"true\">${d}</span></span>`;\n        }\n        rows += `<div role=\"row\" class=\"vs-date-picker-row\"${any ? '' : ' aria-hidden=\"true\"'}>${cells}</div>`;\n      }\n      el.innerHTML = `<div class=\"vs-date-picker-title\" id=\"${tid}\"><span>${fMonth.format(s * DAY)}</span><span>${Math.floor(mi / 12)}</span></div>` +\n        `<div class=\"vs-date-picker-grid\" role=\"grid\" aria-labelledby=\"${tid}\"><div role=\"row\" class=\"vs-date-picker-wk\">${weekdays()}</div>${rows}</div>`;\n      el.style.width = paneW + 'px';\n      vp.appendChild(el); panes.set(mi, el);\n      return el;\n    };\n    const syncPanes = () => {\n      const a = Math.min(Math.floor(view.x), view.g), b = Math.max(Math.ceil(view.x), view.g) + months - 1;\n      for (const [mi, el] of panes) if (mi < a || mi > b) { el.remove(); panes.delete(mi); }\n      let made = false;\n      for (let mi = a; mi <= b; mi++) if (!panes.has(mi)) { makePane(mi); made = true; }\n      if (made) paint();\n      for (const [mi, el] of panes) {\n        const rel = mi - view.x;\n        const out = rel < 0 ? -rel : Math.max(0, rel - (months - 1));\n        const op = clamp(1 - out * 1.35, 0, 1);\n        el.style.transform = `translate3d(${(rel * stride).toFixed(2)}px,0,0)`;\n        el.style.opacity = op.toFixed(3);\n        const away = mi < view.g || mi >= view.g + months;\n        el.style.visibility = op <= 0.001 && away ? 'hidden' : '';\n        el.inert = away;\n      }\n      let rows = 4;\n      for (let mi = view.g; mi < view.g + months; mi++) rows = Math.max(rows, Math.ceil(((dow(mStart(mi)) - o.ws + 7) % 7 + mLen(mi)) / 7));\n      if (rows !== lastRows) { vp.style.setProperty('--rows', rows); lastRows = rows; }\n    };\n\n    const layout = () => {\n      const cs = getComputedStyle(root), em = parseFloat(cs.fontSize) || 16;\n      const len = (name, fb) => { const v = cs.getPropertyValue(name).trim(); const f = parseFloat(v); return isNaN(f) ? fb : /r?em$/.test(v) ? f * (/rem$/.test(v) ? 16 : em) : f; };\n      const pad = parseFloat(cs.paddingLeft) || 0, cell = len('--vs-date-picker-cell', 44), gap = len('--vs-date-picker-gap', 36);\n      const parentW = root.parentElement ? root.parentElement.clientWidth : innerWidth;\n      months = o.months === 2 && parentW >= 2 * 7 * cell * .82 + gap + 2 * pad ? 2 : 1;\n      root.style.setProperty('--m', months);\n      const W = root.clientWidth - 2 * pad;\n      paneW = (W - (months - 1) * gap) / months; stride = paneW + gap;\n      for (const el of panes.values()) el.style.width = paneW + 'px';\n      syncPanes(); navState();\n    };\n\n    // ---- selection painting ----\n    const reach = () => {  // last day a range from `start` can end on (the night before the next blocked date)\n      if (start == null) return Infinity;\n      let lim = o.max;\n      for (const b of o.blocked) if (b > start && b - 1 < lim) lim = b - 1;\n      return lim;\n    };\n    const paint = () => {\n      const lim = reach();\n      const pending = o.mode === 'range' && start != null && end == null;\n      const h = pending && hover != null && hover > start && hover <= lim && !off(hover) ? hover : null;\n      const lo = start, hi = end ?? h;\n      root.classList.toggle('is-preview', end == null && h != null);\n      for (const el of panes.values()) for (const c of el.querySelectorAll('[data-n]')) {\n        const n = +c.dataset.n;\n        const band = lo != null && hi != null && n >= lo && n <= hi;\n        const isS = n === start, isE = n === end;\n        c.classList.toggle('is-in', band);\n        c.classList.toggle('is-s', band && n === lo);\n        c.classList.toggle('is-e', band && n === hi);\n        c.classList.toggle('is-sel', isS || isE);\n        c.classList.toggle('is-pe', n === h);\n        c.classList.toggle('is-far', pending && n > lim && !off(n));\n        c.setAttribute('aria-selected', String(band || isS));\n        if (band) c.style.setProperty('--i', n - lo); else c.style.removeProperty('--i');\n        c.tabIndex = n === focus ? 0 : -1;\n      }\n      // if the focused day isn't on screen, the first enabled visible day takes the tab stop\n      if (![...panes.values()].some((el) => !el.inert && el.querySelector('[tabindex=\"0\"]'))) {\n        const first = [...panes.entries()].filter(([mi]) => mi >= view.g && mi < view.g + months).map(([, el]) => el.querySelector('[data-n]:not(.is-off)')).find(Boolean);\n        if (first) first.tabIndex = 0;\n      }\n    };\n\n    let lastSum = '';\n    const summary = () => {\n      let t = '', b = '';\n      if (o.mode === 'single') t = start != null ? fLong.format(start * DAY) : 'Select a date';\n      else if (start == null) t = 'Select a start date';\n      else if (end == null) t = `${fShort.format(start * DAY)}<i>→</i><em>select an end date</em>`;\n      else {\n        t = `${fShort.format(start * DAY)}<i>–</i>${fShort.format(end * DAY)}`;\n        const n = end - start;\n        if (o.count === 'nights') b = `${n} night${n === 1 ? '' : 's'}`;\n        else if (o.count === 'days') b = `${n + 1} days`;\n      }\n      if (t !== lastSum) { sumT.innerHTML = t; sumT.classList.remove('is-new'); void sumT.offsetWidth; sumT.classList.add('is-new'); lastSum = t; }\n      badge.textContent = b;\n      clearB.hidden = start == null;\n      if (input) input.value = start == null ? '' : o.mode === 'single' ? toISO(start) : `${toISO(start)}/${end == null ? '' : toISO(end)}`;\n      // presets\n      for (const c of presetsEl.children) {\n        const r = presetRange(c.dataset.k);\n        c.setAttribute('aria-pressed', String(!!r && r[0] === start && r[1] === end));\n      }\n    };\n    const presetRange = (k) => {\n      const t = o.today; let s, e;\n      if (k === 'weekend') { const d = dow(t); s = d === 6 ? t : d === 0 ? t + 5 : t + (5 - d); e = d === 6 ? t + 1 : s + 2; }\n      else if (k === 'week') { s = t; e = t + 7; }\n      else if (k === 'fortnight') { s = t; e = t + 14; }\n      else if (k === 'month') { s = t; e = addMonths(t, 1); }\n      else return null;\n      if (o.mode === 'single') e = null;\n      return [s, e];\n    };\n    const presetOk = (r) => { if (!r) return false; for (let n = r[0]; n < (r[1] ?? r[0] + 1); n++) if (off(n)) return false; return r[1] == null || !(r[1] > o.max); };\n    const buildPresets = () => {\n      presetsEl.innerHTML = '';\n      if (o.mode !== 'range') return;\n      for (const p of o.presets) {\n        const r = presetRange(p.k); if (!r) continue;\n        const b = document.createElement('button'); b.type = 'button'; b.className = 'vs-date-picker-chip'; b.dataset.k = p.k; b.textContent = p.l;\n        b.disabled = !presetOk(r);\n        presetsEl.appendChild(b);\n      }\n    };\n\n    const emit = () => {\n      summary();\n      root.dispatchEvent(new CustomEvent('vs-date-change', { bubbles: true, detail: { start: start == null ? null : toISO(start), end: end == null ? null : toISO(end), value: input ? input.value : null } }));\n    };\n    let waveT = 0;\n    const wave = () => { if (reduce.matches) return; root.classList.add('is-wave'); clearTimeout(waveT); waveT = setTimeout(() => root.classList.remove('is-wave'), 700); };\n\n    const pick = (n) => {\n      if (n == null || off(n)) return;\n      focus = n;\n      if (o.mode === 'single') { start = n; end = null; }\n      else if (start == null || end != null || n < start || n > reach()) { start = n; end = null; root.classList.remove('is-wave'); }\n      else if (n === start) { start = null; }\n      else { end = n; wave(); }\n      paint(); emit();\n    };\n\n    // ---- the month spring ----\n    const K = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-date-picker-stiffness')) || 230;\n    const C = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-date-picker-damping')) || 25;\n    let k = K(), c = C();\n    const minM = () => (o.min === -Infinity ? -Infinity : monthOf(o.min));\n    const maxM = () => (o.max === Infinity ? Infinity : monthOf(o.max) - months + 1);\n    const navState = () => { prevB.disabled = view.g <= minM(); nextB.disabled = view.g >= Math.max(minM(), maxM()); };\n    const goTo = (g) => {\n      g = clamp(g, minM(), Math.max(minM(), maxM()));\n      if (g === view.g) return;\n      view.g = g; k = K(); c = C();\n      if (reduce.matches) { view.x = g; view.v = 0; }\n      navState(); syncPanes(); paint(); wake(api);\n    };\n    const ensureVisible = (n) => { const mi = monthOf(n); if (mi < view.g) goTo(mi); else if (mi > view.g + months - 1) goTo(mi - months + 1); };\n\n    const api = {\n      update(dt) {\n        if (drag && drag.on) { syncPanes(); return true; }\n        const n = Math.max(1, Math.round(dt * 120)), h = dt / n;\n        for (let i = 0; i < n; i++) { view.v += (-k * (view.x - view.g) - c * view.v) * h; view.x += view.v * h; }\n        const done = Math.abs(view.v) < .0008 && Math.abs(view.x - view.g) < .0008;\n        if (done) { view.x = view.g; view.v = 0; }\n        syncPanes();\n        return !done;\n      },\n    };\n\n    // ---- input ----\n    const cellOf = (t) => t && t.closest && t.closest('.vs-date-picker-day[data-n]');\n    vp.addEventListener('click', (e) => { if (suppressClick) { suppressClick = false; return; } const cl = cellOf(e.target); if (cl) { kbd = false; pick(+cl.dataset.n); } });\n    vp.addEventListener('pointerover', (e) => { if (e.pointerType === 'touch') return; const cl = cellOf(e.target); const n = cl ? +cl.dataset.n : null; if (n !== hover) { hover = n; kbd = false; paint(); } });\n    vp.addEventListener('pointerleave', () => { if (hover != null && !kbd) { hover = null; paint(); } });\n\n    const focusDay = (n) => {\n      if (n < o.min) n = o.min; if (n > o.max) n = o.max;\n      focus = n; ensureVisible(n); syncPanes();\n      if (o.mode === 'range' && start != null && end == null) hover = n;\n      paint();\n      const el = vp.querySelector(`[data-n=\"${n}\"]`); if (el) el.focus({ preventScroll: true });\n    };\n    vp.addEventListener('keydown', (e) => {\n      const cl = cellOf(e.target); if (!cl) return;\n      const n = +cl.dataset.n, col = (dow(n) - o.ws + 7) % 7; let t = null;\n      switch (e.key) {\n        case 'ArrowLeft': t = n - 1; break; case 'ArrowRight': t = n + 1; break;\n        case 'ArrowUp': t = n - 7; break; case 'ArrowDown': t = n + 7; break;\n        case 'Home': t = n - col; break; case 'End': t = n + 6 - col; break;\n        case 'PageUp': t = addMonths(n, e.shiftKey ? -12 : -1); break;\n        case 'PageDown': t = addMonths(n, e.shiftKey ? 12 : 1); break;\n        case 'Enter': case ' ': e.preventDefault(); kbd = true; pick(n); return;\n        default: return;\n      }\n      e.preventDefault(); kbd = true; focusDay(t);\n    });\n    vp.addEventListener('focusin', (e) => { const cl = cellOf(e.target); if (cl) focus = +cl.dataset.n; });\n\n    const page = (dir) => { goTo(view.g + dir); const f = addMonths(focus, dir); if (!off(f) || f >= o.min) focus = f; paint(); };\n    prevB.addEventListener('click', () => page(-1));\n    nextB.addEventListener('click', () => page(1));\n    clearB.addEventListener('click', () => { start = end = null; hover = null; paint(); emit(); });\n    presetsEl.addEventListener('click', (e) => {\n      const b = e.target.closest('.vs-date-picker-chip'); if (!b || b.disabled) return;\n      const r = presetRange(b.dataset.k); if (!r) return;\n      start = r[0]; end = r[1]; focus = start; hover = null; ensureVisible(start); wave(); paint(); emit();\n    });\n\n    // touch: swipe the months; a flick carries its velocity into the spring\n    vp.addEventListener('pointerdown', (e) => {\n      if (e.pointerType === 'mouse') return;\n      drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, vx0: view.x, on: false, lx: e.clientX, t: performance.now(), v: 0 };\n    });\n    vp.addEventListener('pointermove', (e) => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const dx = e.clientX - drag.x0, dy = e.clientY - drag.y0;\n      if (!drag.on) { if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.2) { drag.on = true; try { vp.setPointerCapture(e.pointerId); } catch (_) {} } else return; }\n      const now = performance.now(), dtm = Math.max(1, now - drag.t);\n      drag.v = drag.v * .5 + (-(e.clientX - drag.lx) / stride / (dtm / 1000)) * .5; drag.lx = e.clientX; drag.t = now;\n      let x = drag.vx0 - dx / stride; const a = minM(), b = Math.max(a, maxM());\n      if (x < a) x = a - .25 * (1 - Math.exp((x - a) / .25)); if (x > b) x = b + .25 * (1 - Math.exp(-(x - b) / .25));\n      view.x = x; view.v = 0; wake(api);\n    });\n    const endDrag = (e) => {\n      if (!drag || e.pointerId !== drag.id) return;\n      if (drag.on) {\n        suppressClick = true; setTimeout(() => (suppressClick = false), 60);\n        const proj = view.x + drag.v * .16; const g = clamp(Math.round(proj), Math.floor(drag.vx0) - 1, Math.ceil(drag.vx0) + 1);\n        view.v = drag.v; drag = null; const d = g - view.g; goTo(g); if (d) { focus = addMonths(focus, d); paint(); }\n        wake(api);\n      } else drag = null;\n    };\n    vp.addEventListener('pointerup', endDrag); vp.addEventListener('pointercancel', endDrag);\n\n    // attribute changes (mode / months / value) re-read the options\n    new MutationObserver(() => {\n      const prevMode = o.mode; read();\n      if (o.mode === 'single' && prevMode !== 'single') end = null;\n      for (const el of panes.values()) el.remove(); panes.clear();\n      buildPresets(); layout(); paint(); summary();\n    }).observe(root, { attributes: true, attributeFilter: ['data-mode', 'data-months', 'data-week-start', 'data-locale', 'data-blocked', 'data-min', 'data-max', 'data-today', 'data-presets', 'data-count'] });\n    const ro = new ResizeObserver(() => layout()); ro.observe(root); if (root.parentElement) ro.observe(root.parentElement);\n\n    root.vsSet = (s, e) => { start = fromISO(s); end = o.mode === 'range' ? fromISO(e) : null; if (start != null) { focus = start; ensureVisible(start); } wave(); paint(); emit(); };\n    root.vsClear = () => { start = end = null; paint(); emit(); };\n    root.vsGoTo = (iso) => { const n = fromISO(iso); if (n != null) goTo(monthOf(n)); };\n    root.__vsDp = api;\n    buildPresets(); layout(); paint(); summary();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-date-picker').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsDatePicker = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Forms",
    "tags": [
      "date picker",
      "calendar",
      "date range",
      "booking",
      "spring",
      "keyboard",
      "aria grid",
      "form"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400&family=Mulish:wght@400;500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#1f5f45",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/date-picker/index.html",
    "thumbnail": "https://vividsites.app/api/component/date-picker/thumb.jpg",
    "video": "https://vividsites.app/api/component/date-picker/thumb.mp4",
    "page": "https://vividsites.app/c/date-picker"
  }
}