| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
1 Requirements | 2 Design review Keyboard review | |||||
1 Docs review | 1 Component review | 2 Theme checks Release prep | 1 Retrospective | |||
1 Requirements | 1 Design review | 1 Keyboard review | 1 Docs review | |||
5 Component review Theme checks | 1 Design review | 1 Keyboard review | 2 Docs review Component review | 1 Theme checks | ||
1 Release prep | 1 Retrospective | |||||
24 events this month, 0 completed.
Filter by category while keeping the default month navigation. Each day shows two entries with a details popover; narrow layouts use counts.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, MonthGrid, Popover, SegmentedControl, Stack, Tag, Text } from '@hina-ui/vue'
import { monthGridSchedule } from '../../month-grid'
const month = ref('2026-09')
const category = ref('all')
const events = monthGridSchedule('en')
const completed = ref<number[]>([])
const filters = [
{ value: 'all', label: 'All' },
{ value: 'design', label: 'Design' },
{ value: 'release', label: 'Release' },
]
const filtered = computed(() =>
events.filter(event => category.value === 'all' || event.kind === category.value),
)
const visible = computed(() => filtered.value.filter(event => event.date.startsWith(month.value)))
const byDate = computed(() => {
const result = new Map<string, typeof events>()
for (const event of filtered.value)
result.set(event.date, [...(result.get(event.date) ?? []), event])
return result
})
const onDate = (date: string) => byDate.value.get(date) ?? []
function toggle(id: number) {
completed.value = completed.value.includes(id)
? completed.value.filter(value => value !== id)
: [...completed.value, id]
}
</script>
<template>
<Stack class="w-full max-w-4xl" gap="sm">
<MonthGrid
v-model:month="month"
today="2026-09-21"
:day-min-height="124"
day-class="@max-[520px]/hn-month-grid:min-h-20"
label="Team schedule"
>
<template #header-actions>
<SegmentedControl
v-model="category"
:options="filters"
size="sm"
aria-label="Schedule category"
/>
</template>
<template #day-trailing="{ date }">
<Text
v-if="onDate(date).length"
size="xs"
tone="muted"
:aria-label="`${onDate(date).length} events`"
class="@max-[520px]/hn-month-grid:hidden"
>
{{ onDate(date).length }}
</Text>
</template>
<template #default="{ date, label }">
<Stack v-if="onDate(date).length" gap="xs" class="min-w-0">
<Text
v-for="event in onDate(date).slice(0, 2)"
:key="event.id"
size="xs"
class="min-w-0 truncate rounded-sm border-s-2 bg-subtle px-1 py-0.5 @max-[520px]/hn-month-grid:hidden"
:class="`${event.kind === 'design' ? 'border-accent' : 'border-warning'} ${completed.includes(event.id) ? 'text-muted line-through' : ''}`"
>
{{ event.title }}
</Text>
<Popover :aria-label="`${label} · events`">
<Button
as-child
variant="ghost"
tone="neutral"
size="sm"
:ripple="false"
class="hn-press-none h-auto w-full min-w-0 justify-start px-1 py-1"
>
<Text
as="button"
type="button"
size="xs"
class="truncate text-start"
:aria-label="`${label}, ${onDate(date).length} events`"
>
<Text as="span" size="inherit" class="@max-[520px]/hn-month-grid:hidden">
{{ onDate(date).length > 2 ? `+${onDate(date).length - 2} more` : 'Details' }}
</Text>
<Text as="span" size="inherit" class="hidden @max-[520px]/hn-month-grid:inline">
{{ onDate(date).length }}
</Text>
</Text>
</Button>
<template #content>
<Stack class="w-60" gap="lg">
<Text size="sm" weight="medium">{{ label }}</Text>
<Stack v-for="event in onDate(date)" :key="event.id" gap="xs">
<Text size="sm">{{ event.time }} · {{ event.title }}</Text>
<Button
size="sm"
variant="link"
tone="neutral"
class="self-start"
@click="toggle(event.id)"
>
{{ completed.includes(event.id) ? 'Undo completion' : 'Mark complete' }}
</Button>
<Tag
v-if="completed.includes(event.id)"
size="sm"
tone="success"
class="self-start"
>
Completed
</Tag>
</Stack>
</Stack>
</template>
</Popover>
</Stack>
</template>
<template #footer>
<Text size="sm" tone="muted">
{{ visible.length }} events this month,
{{ visible.filter(event => completed.includes(event.id)).length }} completed.
</Text>
</template>
</MonthGrid>
<Text size="sm" tone="muted">
Filter by category while keeping the default month navigation. Each day shows two entries with
a details popover; narrow layouts use counts.
</Text>
</Stack>
</template>
Usage
import { MonthGrid } from '@hina-ui/vue'
v-model:month controls the displayed month as YYYY-MM. The default slot receives each day’s metadata and renders below its date number. Add text, status or independent actions.
<MonthGrid v-model:month="month" today="2026-09-21" label="Team schedule">
<template #default="{ date }">
<Text v-for="event in eventsByDate[date]" :key="event.id" size="xs">
{{ event.title }}
</Text>
</template>
</MonthGrid>
| Task | Component |
|---|---|
| Select a date for a form | Calendar / DatePicker |
| Browse daily content or act on a particular day | MonthGrid |
MonthGrid has no selected date, selection highlight or whole-cell click event. Compose buttons, links and overlays inside its slots without nested buttons or intercepted keyboard events.
Examples
Customize around the default date
#day-trailing adds status, holidays or counts beside the date. The component keeps date formatting, today’s marker and the accessible name. Use #date to change only the date content inside the existing time element; keep this slot to text and decoration, with interactive controls in the other slots.
This example hides the built-in header and uses cellClass for full-cell check-in and day-off backgrounds. The default slot holds today’s action. Records belong to the application.
Monthly check-ins
September 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
Off | ||||||
14 check-ins this month. Tinted days are complete; September 25 is a team day off.
<script setup lang="ts">
import { ref } from 'vue'
import { Check } from '@lucide/vue'
import { Button, Inline, MonthGrid, Stack, Text, type MonthGridDay } from '@hina-ui/vue'
const signed = ref([1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 17, 18])
function cellClass(day: MonthGridDay) {
if (day.isOutside) return
if (signed.value.includes(day.day)) return 'bg-accent-soft/40'
if (day.weekday === 0 || day.weekday === 6 || day.date === '2026-09-25') return 'bg-subtle'
}
</script>
<template>
<Stack class="w-full max-w-lg" gap="sm">
<Inline justify="between">
<Text weight="medium">Monthly check-ins</Text>
<Text size="sm" tone="muted">September 2026</Text>
</Inline>
<MonthGrid
month="2026-09"
today="2026-09-21"
:show-header="false"
:show-outside-days="false"
:day-min-height="72"
:fixed-weeks="false"
:cell-class="cellClass"
size="sm"
label="September check-ins"
>
<template #day-trailing="{ date, day, label }">
<Text
v-if="signed.includes(day)"
as="span"
tone="accent"
class="inline-flex"
:aria-label="`${label} · Checked in`"
>
<Check class="size-3" aria-hidden="true" />
</Text>
<Text v-else-if="date === '2026-09-25'" size="xs" tone="muted" aria-label="Team day off">
Off
</Text>
</template>
<template #default="{ isToday, day, label }">
<Button
v-if="isToday && !signed.includes(day)"
size="sm"
variant="soft"
class="hn-press-none h-6 min-w-0 px-1 text-xs"
:aria-label="`${label} · Check in`"
@click="signed.push(day)"
>
+1
</Button>
</template>
</MonthGrid>
<Text size="sm" tone="muted" role="status">
{{ signed.length }} check-ins this month. Tinted days are complete; September 25 is a team day
off.
</Text>
</Stack>
</template>
Full day content and room rates
#day replaces the entire content area, including the date header and default content. The component still owns the table cell and its layout wrapper. Set dayPadding=0 and let the slot button handle its padding to fill the content area.
This example combines dates, prices, stock, sold-out states and application selection. Buttons own their disabled state and aria-pressed; MonthGrid does not hold a booking value.
Nightly rates
September 2026 · per night
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
Choose an available date. Past dates and sold-out dates are disabled.
The action fills the day content area; cellClass controls its background. Prices, availability and selection belong to the caller.
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, MonthGrid, Stack, Text, type MonthGridDay } from '@hina-ui/vue'
import { monthGridPrices } from '../../month-grid'
const prices = monthGridPrices()
const selected = ref('')
const price = (date: string) => prices[date]
function cellClass(day: MonthGridDay) {
if (selected.value === day.date) return 'bg-accent-soft'
if (!day.isOutside && (day.isPast || !price(day.date)?.rooms)) return 'bg-subtle'
}
</script>
<template>
<Stack class="w-full max-w-2xl" gap="sm">
<Inline justify="between" wrap>
<Text weight="medium">Nightly rates</Text>
<Text size="sm" tone="muted">September 2026 · per night</Text>
</Inline>
<MonthGrid
month="2026-09"
today="2026-09-21"
:show-header="false"
:show-outside-days="false"
:fixed-weeks="false"
:day-min-height="104"
:day-padding="0"
:cell-class="cellClass"
day-class="gap-0 @max-[480px]/hn-month-grid:min-h-24"
label="September room rates"
>
<template #day="{ date, dayLabel, label, isPast, isToday }">
<Button
as-child
variant="ghost"
tone="neutral"
size="sm"
:ripple="false"
:disabled="isPast || !price(date)?.rooms"
class="hn-press-none h-auto w-full flex-1 items-center justify-between gap-1 rounded-none border-0 px-1 py-2 text-center focus-visible:outline-offset-[-2px] @min-[480px]/hn-month-grid:items-start @min-[480px]/hn-month-grid:px-3 @min-[480px]/hn-month-grid:text-start"
@click="selected = date"
>
<Stack
as="button"
type="button"
:disabled="isPast || !price(date)?.rooms"
:aria-pressed="selected === date"
:aria-label="`${label}, ${price(date)?.rooms ? `¥${price(date)!.price}, ${price(date)!.rooms} rooms left` : 'Sold out'}`"
>
<Text
as="time"
:datetime="date"
:aria-current="isToday ? 'date' : undefined"
size="sm"
:tone="isToday ? 'accent' : 'default'"
:weight="isToday ? 'medium' : 'normal'"
>
{{ dayLabel }}
</Text>
<Text
v-if="price(date)?.rooms"
size="xs"
:tone="isPast ? 'muted' : 'accent'"
class="@min-[480px]/hn-month-grid:text-sm"
>
¥{{ price(date)!.price }}
</Text>
<Text v-else size="xs" tone="muted">
<Text as="span" size="inherit" class="@max-[480px]/hn-month-grid:hidden">
Sold out
</Text>
<Text as="span" size="inherit" class="hidden @max-[480px]/hn-month-grid:inline">
Full
</Text>
</Text>
<Text size="xs" tone="muted" aria-hidden="true">
<Text as="span" size="inherit" class="@max-[480px]/hn-month-grid:hidden">
{{ isPast ? 'Past' : price(date)?.rooms ? `${price(date)!.rooms} left` : 'Full' }}
</Text>
<Text as="span" size="inherit" class="hidden @max-[480px]/hn-month-grid:inline">
{{ !isPast && price(date)?.rooms ? `${price(date)!.rooms} rm` : '—' }}
</Text>
</Text>
</Stack>
</Button>
</template>
<template #footer>
<Text size="sm" tone="muted" role="status">
{{
selected
? `Selected ${selected}, ¥${price(selected)!.price} / night`
: 'Choose an available date. Past dates and sold-out dates are disabled.'
}}
</Text>
</template>
</MonthGrid>
<Text size="sm" tone="muted">
The action fills the day content area; cellClass controls its background. Prices, availability
and selection belong to the caller.
</Text>
</Stack>
</template>
Add actions without replacing navigation
The schedule example uses #header-actions to add a category filter while keeping the month heading, month/year picker and navigation. Actions wrap onto a separate row in narrow containers. The slot receives the same context as #header.
<MonthGrid v-model:month="month">
<template #header-actions>
<Select v-model="category" :options="categories" aria-label="Schedule category" />
</template>
</MonthGrid>
Providing #header replaces the whole header, including the default actions area. Set :show-header="false" to remove the header and its spacing while retaining the table’s accessible name.
Cell styling and layout
| Hook | Target | Typical use |
|---|---|---|
cellClass | The date’s td | Full-cell state backgrounds, borders and cell padding |
dayClass | The inner content wrapper | Alignment, spacing and layout |
dayMinHeight | Minimum content height | Size the day independently of header controls |
dayPadding | Content padding | Compact layouts or full-area actions |
Both class props accept a string or (day: MonthGridDay) => string | undefined. Callbacks receive the date slot context, including for adjacent-month cells whose content is hidden. Classes merge with defaults so callers can override them without descendant selectors.
Both dimension props accept numbers in pixels or CSS lengths such as 112, '7rem' or 'var(--my-calendar-spacing)'. Padding also accepts shorthand such as '4px 8px'. Omitted values use Hina sizes and responsive spacing. Minimum height does not clip content: extra content can grow its row. Entry limits and “more” controls belong to the application.
Custom header and date bounds
#header exposes the displayed month, date range and navigation methods that respect the bounds. Replace the header with a select or your own toolbar. This example also demonstrates variable week counts, adjacent-month visibility and Sunday-first weeks.
min and max use YYYY-MM-DD. Navigation stays within their months, and out-of-range days receive isDisabled. Bind :disabled="isDisabled" on slot actions yourself; the component does not interfere with application content.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
— | — | — | — | — | ||
— | — | — | — | Open | Open | Open |
Open | Open | Open | Open | Open | Open | Open |
Open | Open | Open | Open | Open | Open | Open |
Open | Open | Open | Open |
Available September 10 – November 20. Weeks begin on Sunday.
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { IconButton, Inline, MonthGrid, Select, Stack, Switch, Text } from '@hina-ui/vue'
const month = ref('2026-09')
const fixed = ref(false)
const outside = ref(false)
const options = [
{ label: 'September 2026', value: '2026-09' },
{ label: 'October 2026', value: '2026-10' },
{ label: 'November 2026', value: '2026-11' },
]
</script>
<template>
<Stack class="w-full max-w-lg">
<Inline gap="lg" wrap>
<Switch v-model="fixed">Fixed six weeks</Switch>
<Switch v-model="outside">Adjacent months</Switch>
</Inline>
<MonthGrid
v-model:month="month"
today="2026-09-21"
min="2026-09-10"
max="2026-11-20"
:fixed-weeks="fixed"
:show-outside-days="outside"
:week-starts-on="0"
size="sm"
label="Available dates"
>
<template #header="{ prev, next, canPrev, canNext }">
<IconButton label="Previous month" size="sm" :disabled="!canPrev" @click="prev">
<ChevronLeft class="rtl:rotate-180" />
</IconButton>
<Select
v-model="month"
:options="options"
size="sm"
aria-label="Month"
class="min-w-0 flex-1"
/>
<IconButton label="Next month" size="sm" :disabled="!canNext" @click="next">
<ChevronRight class="rtl:rotate-180" />
</IconButton>
</template>
<template #default="{ isDisabled, isOutside }">
<Text v-if="!isOutside" size="xs" tone="muted" class="text-center">
{{ isDisabled ? '—' : 'Open' }}
</Text>
</template>
<template #footer>
<Text size="xs" tone="muted">
Available September 10 – November 20. Weeks begin on Sunday.
</Text>
</template>
</MonthGrid>
</Stack>
</template>
Loading data
range-change fires after client mount, then when the month, first weekday or week count changes. It provides the month and the table’s first and last ISO dates, allowing requests to include adjacent months.
<MonthGrid v-model:month="month" @range-change="loadRange">
<template #default="{ date }">
<Text size="xs">{{ summaries[date] }}</Text>
</template>
</MonthGrid>
Requests, loading feedback, failures and stale-response handling belong to the caller. Prefetch SSR data from the known month at page level instead of depending on the client-only event. The range always describes the whole table, even when adjacent-month content is hidden.
First paint and layout
- The server renders weekday headings, all date cells and slot content directly. Layout needs no browser measurement.
- Today defaults to UTC so server and browser local time zones do not disagree. Pass
timeZonefor a fixed business zone. Pass the sametodayandmonthfrom the page when hydration must remain identical across midnight as well. - Six fixed weeks keep the row count stable between months. With
fixedWeeks=false, the table uses the four to six weeks needed by that month. sizeprovides default day and control dimensions.dayMinHeightanddayPaddingindependently override day layout. Content can grow naturally; narrow containers reduce default padding. Limit long event lists or show counts, then open details in a Popover or Dialog.- Dates use the Gregorian calendar with localized month and weekday names. Week start follows the locale unless overridden. Direction is inherited and RTL is supported.
- Invalid months fall back to today’s month. Valid out-of-range months display the nearest allowed month. Invalid bounds are ignored; reversed bounds are ignored together. These fallbacks do not rewrite the caller’s model automatically.
Accessibility
The display uses a native table, caption and weekday headers with scope="col". Default date numbers are time elements with full date names, and today has aria-current="date". Cells do not form a selectable ARIA grid or add Tab stops.
Slot buttons and links retain their normal Tab order. When replacing entire cells, preserve date names and provide accessible names for actions and status icons.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
month | string | Today’s month | YYYY-MM, supports v-model:month |
dir | 'ltr' | 'rtl' | Inherited | Direction for the table and month/year picker |
today | string | Computed in the time zone | YYYY-MM-DD, overrides automatic calculation |
timeZone | string | 'UTC' | IANA time zone for calculating today |
min / max | string | — | Date bounds as YYYY-MM-DD |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | Locale | First weekday; 0 is Sunday |
weekdayFormat | 'narrow' | 'short' | 'long' | 'short' | Weekday label format |
fixedWeeks | boolean | true | Always render six weeks |
showOutsideDays | boolean | true | Render adjacent-month content; false preserves blank cells |
showToday | boolean | true | Show the current-month shortcut in the default header |
disabled | boolean | false | Disable default navigation and provide disabled metadata |
size | 'sm' | 'md' | 'lg' | 'md' | Cell and navigation size |
label | string | Localized “Calendar” | Table name; the current month is appended |
class | string | — | Root styling; native attributes and style also reach the root |
showHeader | boolean | true | Render the built-in header or header slot; false leaves no spacer |
dayMinHeight | number | string | From size | Minimum day content height; numbers are pixels, content can grow |
dayPadding | number | string | Responsive | Day content padding; numbers are pixels, use 0 for full-area actions |
cellClass | string | ((day: MonthGridDay) => string | undefined) | — | Styles for date td elements |
dayClass | string | ((day: MonthGridDay) => string | undefined) | — | Styles for date content wrappers |
Slots
| Slot | Context | Description |
|---|---|---|
default | MonthGridDay | Content below the date header |
day | MonthGridDay | Replaces the content area, preserving td and layout wrapper; takes precedence over date, day-trailing and default |
date | MonthGridDay | Content inside time, preserving date semantics and today’s marker |
day-trailing | MonthGridDay | Supplementary content beside the default date |
header | MonthGridHeader | Replaces the complete navigation header |
header-actions | MonthGridHeader | Actions beside default navigation; wraps on narrow containers |
weekday | { day, label, fullLabel } | Weekday heading; day is 0–6 |
footer | { month, start, end } | Supplementary table content |
MonthGridDay includes date (ISO date), day (day number), dayLabel (localized number text), weekday (0–6, Sunday is 0), label (full date name), isToday, isPast, isFuture, isOutside and isDisabled. Past/future compare dates against the same today. The caller defines weekends, holidays and business availability.
MonthGridHeader includes month, start, end, label, canPrev, canNext, canToday, prev(), next() and goToToday().
Events
| Event | Payload | Description |
|---|---|---|
update:month | string | Displayed month changes through navigation |
range-change | { month, start, end } | Initial client mount and displayed-range changes |
Expose
| Name | Type | Description |
|---|---|---|
range | MonthGridRange | Current month and table date range |
prev / next | () => void | Navigate within bounds, respecting disabled state |
goToToday | () => void | Navigate to today’s month within bounds |