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
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/** Month swap fade. React 19 hoisted style — no Tailwind config edits needed. */
const KEYFRAMES = `@keyframes mc-month-in{from{opacity:0}to{opacity:1}}`

/** Always six rows so the card keeps one height across months. */
const WEEK_ROWS = 6
const DAYS_PER_WEEK = 7
/** How far arrow keys keep looking for an enabled day before giving up. */
const MAX_SKIP = 62

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mini-calendar.json

Prompt

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 locale defaults to a fixed string instead of the browser's language.
  • Controlled by omission — value !== undefined picks the mode, which keeps null meaning "nothing selected" instead of accidentally meaning "uncontrolled".

On This Page