Mini calendar
A compact month grid with a mark on the days that carry something: dots, counts or a three-state fill, and the arrow keys to walk it.
The small calendar beside a page, not a replacement for DatePicker or Calendar: no popover, no range, no field. Everything is UTC — Date.UTC in, getUTC* out, marks keyed yyyy-mm-dd (exactly what toISOString().slice(0, 10) gives) — so midnight on the 1st is the 1st for every reader, where a grid built from local parts puts it in the month before for anyone west of Greenwich. Two ownership rules, both by presence: pass onMonthChange and the month is yours, so the calendar renders exactly the month you hand back; leave it off and month is the month it opens on and the arrows move it themselves. value follows the picker rule the rest of the registry uses — the key being there at all, even as undefined, means you own the choice — so clearing a controlled calendar never hands it back its own state. The grid is a real role="grid": one day in the tab order at a time, arrows walking the month and stepping into the next or previous one when they run off the end, Home and End for its two ends. aria-selected sits on the cell, where a grid puts it, and each day button carries its full date as its name plus whatever its mark adds — "3 events", "limited" — because a dot is not a label. countLabel names what is being counted; the default reads "1 event", "3 events", and a page whose marks are something else — viewings, bookings — passes its own.
Install
npx shadcn@latest add @vibra/mini-calendarNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Card, CardContent } from "@/components/ui/card"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
// Pinned rather than read off the clock, so the docs render one calendar on
// the server and the same one in the browser.
const TODAY = day("2026-09-04")
// What is on this month: a standup every weekday is noise, so only the days
// with something worth a dot carry one.
const MARKS: Record<string, MiniCalendarMark> = {
"2026-09-02": { tone: "info", count: 2 },
"2026-09-04": { tone: "brand", count: 1 },
"2026-09-09": { tone: "success", count: 3 },
"2026-09-15": { tone: "warning", count: 1 },
"2026-09-17": { tone: "info", count: 4 },
"2026-09-23": { tone: "danger", count: 1 },
"2026-09-29": { tone: "success", count: 2 },
}
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })
export default function MiniCalendarDemo() {
const [month, setMonth] = React.useState(day("2026-09-01"))
const [value, setValue] = React.useState<Date | undefined>(TODAY)
const key = value?.toISOString().slice(0, 10)
const count = key ? (MARKS[key]?.count ?? 0) : 0
return (
<Card className="w-fit">
<CardContent className="flex flex-col gap-3">
<MiniCalendar
month={month}
onMonthChange={setMonth}
value={value}
onValueChange={setValue}
marks={MARKS}
today={TODAY}
size="sm"
/>
<p className="max-w-56 text-xs text-muted-foreground">
{value
? `${FULL.format(value)} — ${count === 0 ? "nothing scheduled" : `${count} event${count === 1 ? "" : "s"}`}.`
: "Pick a day."}
</p>
</CardContent>
</Card>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| month | Date | — | The month on show, read through its UTC parts. Owned by you when onMonthChange is given, otherwise the month it opens on. |
| value | Date | — | The chosen day, at midnight UTC. Passing the value key at all — even as undefined — makes the choice yours. |
| onValueChange | (date: Date) => void | — | Called when the reader picks a day, at midnight UTC. |
| onMonthChange | (month: Date) => void | — | The reader asked for another month — from the arrows, or from a key that stepped off the end of this one. |
| marks | Record<string, { tone?; count?: number; fill?: "available" | "partial" | "full" }> | — | Keyed yyyy-mm-dd in UTC. count draws up to three dots and is read out in full; fill washes the whole cell and names its state. |
| today | Date | — | Ringed in ink rather than in the accent — the accent is spent on the day the reader chose. |
| weekStartsOn | 0 | 1 | 1 | Sunday or Monday in the first column. |
| size | "sm" | "default" | "default" | Cell type scale and the size of the two month buttons. |
| countLabel | (count: number) => string | (count) => `${count} event${count === 1 ? "" : "s"}` | What a mark's count is read out as, after the date — pass your own noun (e.g. "2 viewings") when the marks are not events. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DAY_MS,
FULL_DAY,
MINI_CALENDAR_FILL,
MINI_CALENDAR_FILL_LABELS,
MONTH_YEAR,
TONE_DOT,
WEEKDAYS,
dayKey,
monthCells,
moveMonth,
sameMonth,
startOfDay,
startOfMonth,
type MiniCalendarMark,
} from "./days"
export type MiniCalendarProps = React.ComponentProps<"div"> & {
/**
* The month on show. Pass `onMonthChange` and it is yours — the calendar
* renders exactly what you hand it. Leave that off and this is the month it
* opens on, and the arrows move it from there.
*/
month: Date
/** The chosen day. Present, even as `undefined`, means you own the choice. */
value?: Date
onValueChange?: (date: Date) => void
onMonthChange?: (month: Date) => void
/** Keyed `yyyy-mm-dd`, in UTC — the same key `toISOString().slice(0, 10)` gives. */
marks?: Record<string, MiniCalendarMark>
today?: Date
weekStartsOn?: 0 | 1
size?: "sm" | "default"
/** What a mark's count is called, read out after the date: "3 events" by default. */
countLabel?: (count: number) => string
}
/** "1 event", "3 events": the default noun a mark's count is read out with. */
const DEFAULT_COUNT_LABEL = (count: number) => `${count} event${count === 1 ? "" : "s"}`
/**
* A compact month grid: one cell per day, marks on the days that have
* something on them, and the arrow keys to walk it.
*
* Every date is a UTC instant at midnight — `Date.UTC` in, `getUTC*` out — so
* the day under the cursor is the same day for a reader in Los Angeles and one
* in Berlin, and a mark keyed `2026-09-08` lands on the eighth either way.
* This is the small calendar beside a page, not a replacement for DatePicker
* or Calendar: it has no popover, no range and no field.
*/
function MiniCalendar(props: MiniCalendarProps) {
const {
className,
month,
value,
onValueChange,
onMonthChange,
marks,
today,
weekStartsOn = 1,
size = "default",
countLabel = DEFAULT_COUNT_LABEL,
...rest
} = props
// A picker whose value may legitimately be undefined takes the presence of
// the key as the controlled signal, so `value={undefined}` still means "mine".
const valueOwned = "value" in props
const monthOwned = onMonthChange !== undefined
const [ownValue, setOwnValue] = React.useState<Date | undefined>(value)
const [ownMonth, setOwnMonth] = React.useState(() => startOfMonth(month))
const [focusKey, setFocusKey] = React.useState<string | null>(null)
const dayNodes = React.useRef(new Map<string, HTMLButtonElement | null>())
const pendingFocus = React.useRef<string | null>(null)
React.useEffect(() => {
const key = pendingFocus.current
if (!key) return
pendingFocus.current = null
dayNodes.current.get(key)?.focus()
})
const shown = monthOwned ? startOfMonth(month) : ownMonth
const selected = valueOwned ? value : ownValue
const selectedKey = selected ? dayKey(startOfDay(selected)) : null
const todayKey = today ? dayKey(startOfDay(today)) : null
const year = shown.getUTCFullYear()
const monthIndex = shown.getUTCMonth()
const { days, weeks } = monthCells(shown, weekStartsOn)
// Exactly one day is in the tab order: wherever focus last was, else the
// chosen day, else today, else the first of the month.
const inMonth = (key: string | null) => key !== null && key.slice(0, 7) === dayKey(shown).slice(0, 7)
const tabKey =
(inMonth(focusKey) ? focusKey : null) ??
(inMonth(selectedKey) ? selectedKey : null) ??
(inMonth(todayKey) ? todayKey : null) ??
dayKey(days[0])
function changeMonth(next: Date, focusOn?: Date) {
if (!monthOwned) setOwnMonth(next)
onMonthChange?.(next)
if (focusOn) {
const key = dayKey(focusOn)
setFocusKey(key)
pendingFocus.current = key
} else {
setFocusKey(null)
}
}
function pick(date: Date) {
if (!valueOwned) setOwnValue(date)
setFocusKey(dayKey(date))
onValueChange?.(date)
}
/** Moves focus by `step` days, changing the month when it steps off the end. */
function moveTo(target: Date) {
if (sameMonth(target, shown)) {
const key = dayKey(target)
setFocusKey(key)
dayNodes.current.get(key)?.focus()
return
}
changeMonth(startOfMonth(target), target)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, date: Date) {
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
if (event.key in step) {
event.preventDefault()
moveTo(new Date(date.getTime() + step[event.key] * DAY_MS))
return
}
if (event.key === "Home") {
event.preventDefault()
moveTo(days[0])
return
}
if (event.key === "End") {
event.preventDefault()
moveTo(days[days.length - 1])
return
}
if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault()
moveTo(moveMonth(date, event.key === "PageUp" ? -1 : 1))
}
}
const heading = MONTH_YEAR.format(shown)
return (
<div
data-slot="mini-calendar"
data-size={size}
data-week-start={weekStartsOn}
className={cn("w-fit min-w-56 text-sm", className)}
{...rest}
>
<div data-slot="mini-calendar-header" className="flex items-center justify-between gap-1 pb-1">
<Button
type="button"
variant="ghost"
size={size === "sm" ? "icon-xs" : "icon-sm"}
aria-label="Previous month"
onClick={() => changeMonth(new Date(Date.UTC(year, monthIndex - 1, 1)))}
>
<ChevronLeftIcon className="rtl:rotate-180" />
</Button>
<div data-slot="mini-calendar-month" className="type-label" aria-live="polite">
{heading}
</div>
<Button
type="button"
variant="ghost"
size={size === "sm" ? "icon-xs" : "icon-sm"}
aria-label="Next month"
onClick={() => changeMonth(new Date(Date.UTC(year, monthIndex + 1, 1)))}
>
<ChevronRightIcon className="rtl:rotate-180" />
</Button>
</div>
<table role="grid" aria-label={heading} className="w-full border-collapse">
<thead>
<tr>
{Array.from({ length: 7 }, (_, index) => {
const [short, long] = WEEKDAYS[(index + weekStartsOn) % 7]
return (
<th key={short} scope="col" className="type-eyebrow pb-1 font-normal text-faint-foreground">
<span aria-hidden="true">{short}</span>
<span className="sr-only">{long}</span>
</th>
)
})}
</tr>
</thead>
<tbody>
{weeks.map((week, index) => (
<tr key={index}>
{week.map((date, position) => {
if (!date) return <td key={position} role="gridcell" className="p-0" />
const key = dayKey(date)
const mark = marks?.[key]
const isSelected = key === selectedKey
const isToday = key === todayKey
const name = [
FULL_DAY.format(date),
mark?.count ? countLabel(mark.count) : null,
mark?.fill ? MINI_CALENDAR_FILL_LABELS[mark.fill] : null,
]
.filter(Boolean)
.join(", ")
return (
<td key={position} role="gridcell" aria-selected={isSelected} className="p-0">
<button
type="button"
ref={(node) => {
dayNodes.current.set(key, node)
}}
data-slot="mini-calendar-day"
data-day={key}
data-today={isToday || undefined}
data-selected={isSelected || undefined}
data-mark={mark?.tone}
data-fill={mark?.fill}
tabIndex={key === tabKey ? 0 : -1}
aria-label={name}
aria-current={isToday ? "date" : undefined}
onClick={() => pick(date)}
onKeyDown={(event) => handleKeyDown(event, date)}
className={cn(
"relative flex aspect-square w-full items-center justify-center rounded-md tabular-nums focus-ring",
"transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted",
size === "sm" ? "text-2xs" : "text-xs",
mark?.fill && MINI_CALENDAR_FILL[mark.fill],
// Today is ink, never the accent: the accent is spent
// on the day the reader chose.
isToday && !isSelected && "font-medium ring-1 ring-inset ring-foreground/40",
isSelected && "bg-brand font-medium text-brand-foreground hover:bg-brand"
)}
>
{date.getUTCDate()}
{mark?.tone || mark?.count ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0.5 flex justify-center gap-px"
>
{Array.from({ length: Math.min(mark.count ?? 1, 3) }, (_, dot) => (
<span
key={dot}
className={cn(
"size-1 rounded-full",
isSelected ? "bg-brand-foreground" : TONE_DOT[mark.tone ?? "neutral"]
)}
/>
))}
</span>
) : null}
</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
export { MiniCalendar, MINI_CALENDAR_FILL, MINI_CALENDAR_FILL_LABELS }
export type { MiniCalendarFill, MiniCalendarMark, MiniCalendarTone } from "./days"/**
* The day vocabulary the grid is built from: UTC-only date arithmetic, the
* keys a mark is looked up by, the weekday names, and the paint for a tone or
* a fill.
*
* Every function here reads `getUTC*` and writes `Date.UTC`, so a day is the
* same day in Los Angeles and in Berlin — a calendar that reads local parts
* puts midnight UTC on the 1st into the month before for half the planet.
*/
/** How a marked day reads: a hue, a count, or one of three fill states. */
export type MiniCalendarTone = "neutral" | "brand" | "success" | "warning" | "danger" | "info"
export type MiniCalendarFill = "available" | "partial" | "full"
export type MiniCalendarMark = {
tone?: MiniCalendarTone
/** How many things are on that day; dots up to three, and read out in full. */
count?: number
/** A whole-cell wash: how much of the day is taken. */
fill?: MiniCalendarFill
}
export const WEEKDAYS = [
["Su", "Sunday"],
["Mo", "Monday"],
["Tu", "Tuesday"],
["We", "Wednesday"],
["Th", "Thursday"],
["Fr", "Friday"],
["Sa", "Saturday"],
] as const
export const TONE_DOT: Record<MiniCalendarTone, string> = {
neutral: "bg-muted-foreground",
brand: "bg-brand",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
info: "bg-info",
}
/**
* The three fills: a tint with the tone's own ink on it, the pairing
* StatusBadge uses — `-foreground` is the ink for a *solid* fill and reads at
* 1.1:1 on the tint, which is a day number nobody can read.
*/
export const MINI_CALENDAR_FILL: Record<MiniCalendarFill, string> = {
available: "bg-success-muted text-success",
partial: "bg-warning-muted text-warning",
full: "bg-danger-muted text-danger",
}
/** What each fill is called, in the day's own accessible name and in a legend. */
export const MINI_CALENDAR_FILL_LABELS: Record<MiniCalendarFill, string> = {
available: "available",
partial: "limited",
full: "full",
}
export const MONTH_YEAR = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "UTC",
})
export const FULL_DAY = new Intl.DateTimeFormat("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
export const DAY_MS = 86_400_000
/** Midnight UTC on the first of the month a date falls in. */
export function startOfMonth(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
}
/** Midnight UTC of the day a date names. */
export function startOfDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
/** The `yyyy-mm-dd` key a mark is looked up by. */
export function dayKey(date: Date): string {
return date.toISOString().slice(0, 10)
}
export function sameMonth(a: Date, b: Date): boolean {
return a.getUTCFullYear() === b.getUTCFullYear() && a.getUTCMonth() === b.getUTCMonth()
}
/**
* The same day of the month, `months` away — clamped into whatever that
* month actually has, so August 31 minus a month lands on July 31 but March
* 31 minus a month lands on February 28 (or 29), never rolling into March.
*/
export function moveMonth(date: Date, months: number): Date {
const year = date.getUTCFullYear()
const month = date.getUTCMonth() + months
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)))
}
/** The days of the month `shown` names, plus the blanks that lead into it. */
export function monthCells(shown: Date, weekStartsOn: 0 | 1): { days: Date[]; weeks: (Date | null)[][] } {
const year = shown.getUTCFullYear()
const month = shown.getUTCMonth()
const length = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
const lead = (shown.getUTCDay() - weekStartsOn + 7) % 7
const days = Array.from({ length }, (_, index) => new Date(Date.UTC(year, month, index + 1)))
const cells: (Date | null)[] = [...Array.from({ length: lead }, () => null), ...days]
while (cells.length % 7 !== 0) cells.push(null)
const weeks = Array.from({ length: cells.length / 7 }, (_, week) => cells.slice(week * 7, week * 7 + 7))
return { days, weeks }
}