Display
Mini Calendar
A compact month grid for picking one date — keyboard-navigable, timezone-safe, with today, disabled days and event dots. No date library.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mini-calendar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MiniCalendar" component: a compact
single-date month grid. Icons from lucide-react, no date library — plain Date
arithmetic only.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>
(defaultValue/onChange omitted so they can be re-typed).
- Selection: controlled via value?: Date | null + onValueChange?(date), or
uncontrolled via defaultValue?: Date | null. `value !== undefined` is what
decides which mode you are in, so null stays a legal "nothing selected".
- Month: controlled via month?: Date (any day inside it) + onMonthChange?(month),
or uncontrolled — the internal month seeds from month ?? value ??
defaultValue ?? today.
- weekStartsOn?: 0 | 1 (default 1), locale?: string (Intl for weekday, month
and full-date names; defaults to a FIXED "en-US" — pass your app's own locale
to localise, never fall back to the visitor's browser language: the server
would format one language and the browser another and hydration would throw),
disabledDate?: (d: Date) => boolean, markedDates?: Date[] (dot under the
number), min?/max?: Date
(inclusive, compared by calendar day), showOutsideDays?: boolean (default
true). className merges via cn().
Behavior
- Grid: always six rows of seven days so the card never changes height between
months. The first cell is the month's first day minus
((firstDay.getDay() - weekStartsOn + 7) % 7) days.
- Timezone safety: every helper builds dates from local year/month/day parts
and compares year+month+day. Nothing goes through toISOString() or getUTC*,
so a date never slides a day for a visitor in another timezone.
- "Today" comes from useSyncExternalStore whose server snapshot is null: the
server (and the first paint) render no today marker at all and React swaps
the real one in right after hydration, instead of the server guessing a day
the visitor may not be living in yet.
- Hydration in general: the clock and the locale are the only two things that
can differ between the server and the visitor, so neither is ever read
implicitly — today goes through that null server snapshot, and locale
defaults to a fixed string rather than the browser's language. Seed the
visible month with month/defaultValue when you server-render, so the first
markup is byte-identical instead of depending on which day it is where the
renderer stands.
- Accessibility: role="grid" labelled by the caption, a rowgroup of
role="columnheader" weekday cells, then role="gridcell" wrappers carrying
aria-selected around a real <button> per day. Today gets aria-current="date".
The caption is aria-live="polite" so paging announces the new month.
- Roving tabindex: exactly one cell is tabbable — the focused day, else the
selection, else today, else the first day of the month that can actually take
focus (never a disabled button, which would be a Tab dead end).
- Keyboard on the grid: Left/Right ±1 day, Up/Down ±1 week, Home/End the first
and last day of that week, PageUp/PageDown the same day one month away
(clamped: Jan 31 -> Feb 28), Enter/Space selects. Moves that land outside the
visible month page the calendar to it automatically. Disabled days are
skipped by walking on in the direction of travel (bounded, ~2 months) so
focus never parks somewhere it can't act.
- Because a month swap re-creates every cell, moves set a "focus pending" ref
and an effect restores DOM focus after the render — clicking the arrows
instead clears the focused day and leaves focus on the arrow button.
- Month change plays a short opacity keyframe on the rowgroup, keyed by
year-month so React remounts it; the keyframe is dropped under
prefers-reduced-motion. Nothing about paging depends on it.
Rendering & styling
- Card: inline-flex w-fit rounded-xl border bg-card p-3 select-none. Header row
is caption text-sm font-medium between two ghost icon buttons (ChevronLeft /
ChevronRight, aria-label "Previous month" / "Next month"), each disabled when
min/max makes the whole neighbouring month unreachable.
- Day cell: size-9 rounded-md text-sm tabular-nums, hover:bg-accent, selected =
bg-primary text-primary-foreground, today (unselected) = ring-1 ring-primary,
outside days = text-muted-foreground (they are clickable, so they keep
text-level contrast; dimming them further drops them under 3:1 on a light
card), disabled = text-muted-foreground/50
+ pointer-events-none + the disabled attribute. Focus ring is
focus-visible:ring-2 focus-visible:ring-ring. Semantic tokens only.
- Event dot: an absolutely positioned size-1 rounded-full under the number,
bg-primary normally and bg-primary-foreground on the selected day so it stays
visible; the day's aria-label gains ", has events" so the dot isn't
sight-only. The @keyframes ships via a React 19 <style href precedence> tag.
Customization levers
- Density: the size-9 cells and gap-1 rows are the whole layout — size-8/gap-0.5
for a sidebar, size-11 for a touch target. Nothing else assumes a cell size.
- Marker vocabulary: markedDates + one dot today; swap the dot for a count
badge, or key a colour off a Map<dateKey, status> to show
available/busy/blocked with var(--chart-N).
- Range selection: the grid is deliberately single-date. To extend it, keep
this component and lift a {start, end} pair into the parent, feeding
`selected` styling from a "date is between" predicate rather than teaching
the cell two selection states.
- Locale + week start: locale drives every visible string through Intl, so
zh-CN / ja-JP need no strings from you; weekStartsOn flips Sunday/Monday. Pass
the locale explicitly from your app's own i18n state — the default is fixed
precisely so nothing ever silently follows navigator.language. The only
hardcoded English is the two arrow aria-labels and ", has events".
- Popover date picker: wrap this in your Popover, render the formatted
selection in the trigger, close on onValueChange — the component itself stays
layout-free.
- Bounds: min/max are the cheap guardrails (no past days, 90-day booking
window); disabledDate is the expensive one (blackout list, weekends,
server-known availability) and runs per rendered cell, so keep it O(1) —
precompute a Set of keys rather than scanning an array inside it.Concepts
- Roving tabindex — the grid holds a single tab stop and the arrow keys move focus inside it, so a keyboard user reaches the calendar in one Tab instead of forty-two.
- Focus that survives a month swap — paging re-creates every cell, so a move records "focus pending" and an effect re-focuses the target after the render; without it, arrowing from the 31st into the next month would silently drop focus to the body.
- Enabled-only navigation — disabled days are real dead ends for keyboard users, so movement walks past them in the direction of travel instead of landing on them and stalling.
- Local-date comparison — every date is built and compared from local year/month/day parts, never via UTC conversion; "the same day" therefore means the same day the visitor sees on their wall calendar.
- Server snapshot null for today — the server can't know the visitor's calendar day, so it renders no today marker and lets hydration fill it in, trading one frame for zero mismatched markup; the same rule is why
localedefaults to a fixed string instead of the browser's language. - Controlled by omission —
value !== undefinedpicks the mode, which keepsnullmeaning "nothing selected" instead of accidentally meaning "uncontrolled".