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…
- Dependencies
- lucide-react
- Registry dependencies
- utils
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, and pages along when a controlled value moves to
another month (a parent "Today" button, a form reset). That sync is the
"adjust state on prop change" pattern: keep the last seen value.getTime()
in state, compare with Object.is during render (an invalid Date is NaN and
must not loop), and only page when the new value is outside the view. An
invalid Date never pages the view.
- 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
aria-disabled (click guarded, aria-disabled:opacity-40
aria-disabled:pointer-events-none) when min/max makes the whole neighbouring
month unreachable. Never the native disabled attribute: paging onto the min
or max month disables the button that has focus, and a natively disabled
button drops that focus to <body>.
- 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".