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…

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

On This Page