MonthGrid

Display schedules, check-ins, prices or daily status in a monthly table.

Team schedule · September 2026
SunMonTueWedThuFriSat

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>
vue

Usage

import { MonthGrid } from '@hina-ui/vue'
ts

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>
vue
TaskComponent
Select a date for a formCalendar / DatePicker
Browse daily content or act on a particular dayMonthGrid

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

September check-ins · September 2026
SunMonTueWedThuFriSat

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>
vue

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

September room rates · September 2026
SunMonTueWedThuFriSat

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>
vue

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>
vue

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

HookTargetTypical use
cellClassThe date’s tdFull-cell state backgrounds, borders and cell padding
dayClassThe inner content wrapperAlignment, spacing and layout
dayMinHeightMinimum content heightSize the day independently of header controls
dayPaddingContent paddingCompact 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.

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>
vue

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 timeZone for a fixed business zone. Pass the same today and month from 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.
  • size provides default day and control dimensions. dayMinHeight and dayPadding independently 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

SlotContext
Description
defaultMonthGridDay
Content below the date header
dayMonthGridDay
Replaces the content area, preserving td and layout wrapper; takes precedence over date, day-trailing and default
dateMonthGridDay
Content inside time, preserving date semantics and today’s marker
day-trailingMonthGridDay
Supplementary content beside the default date
headerMonthGridHeader
Replaces the complete navigation header
header-actionsMonthGridHeader
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