Which months you have: one row per year, each month held, partial or missing, said in words too.
Your states, three looks
Example
Example
Missing: November 2024, March and April 2025. Partial: June 2025.
| Year | January | February | March | April | May | June | July | August | September | October | November | December |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026 | Held | Held | Held | Held | Held | Held | Held | Held | Held | Held | Not in range | Not in range |
| 2025 | Held | Held | Missing | Missing | Held | Partial | Held | Held | Held | Held | Held | Held |
| 2024 | Not in range | Not in range | Held | Held | Held | Held | Held | Held | Held | Held | Missing | Held |
import { Card, MonthGrid, type MonthGridState, type MonthGridYear } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** Your states, in your words, each mapped to one of three looks. */
const STATES: MonthGridState[] = [
{ id: 'held', label: 'Held', look: 'filled' },
{ id: 'partial', label: 'Partial', look: 'partial' },
{ id: 'missing', label: 'Missing', look: 'empty' },
];
const H = 'held';
const P = 'partial';
const M = 'missing';
/** Newest first. A month outside the range, before the account opened or not yet come, is null and stays blank. */
const YEARS: MonthGridYear[] = [
{ id: 2026, label: '2026', months: [H, H, H, H, H, H, H, H, H, H, null, null] },
{ id: 2025, label: '2025', months: [H, H, M, M, H, P, H, H, H, H, H, H] },
{ id: 2024, label: '2024', months: [null, null, H, H, H, H, H, H, H, H, M, H] },
];
export default function MonthGridBasic(): ReactElement {
return (
// The grid takes no width from its months, so a holder sized by its content, as this card is here, gives it one.
<Card style={{ width: '100%', maxWidth: '40rem' }}>
<MonthGrid
label="Statement coverage"
states={STATES}
years={YEARS}
monthLabels={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']}
monthNames={[
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]}
/>
</Card>
);
}A month grid shows which months you have of something that comes monthly, such as an account's statements: one row per year, one cell per month. The states are yours, in your words, such as Held, Partial and Missing. Each maps to one of three looks: filled is a solid cell, partial a striped one and empty an outline. A month outside the range, before the account opened or not yet come, is null and stays blank, so it never reads as missing.
The looks are shapes, not meanings. None of them is a color of error, warning or success: a missing statement is the reader's fact, not a failure of the page. Under the grid a sentence says what is missing or partial, and a legend names your states.
The looks
Any words map onto the three looks. Here a statement reconciled is filled, one uploaded and not yet checked is partial, and one not received is an outline; the account opened in May 2025, so the months before it are blank. Two states may share a look, but the reader then tells them apart only in the table and the summary, so keep to a few states with a look each.
Example
Example
Not received: September 2026. Uploaded, not checked: July, August and October 2026.
| Year | January | February | March | April | May | June | July | August | September | October | November | December |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026 | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled | Uploaded, not checked | Uploaded, not checked | Not received | Uploaded, not checked | Not in range | Not in range |
| 2025 | Not in range | Not in range | Not in range | Not in range | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled | Reconciled |
import { Card, MonthGrid, type MonthGridState } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/**
* Other words for the same three looks: a statement reconciled is filled, one uploaded and not yet checked is
* partial, and one not received is an outline. None of them is a color of warning or error.
*/
const STATES: MonthGridState[] = [
{ id: 'reconciled', label: 'Reconciled', look: 'filled' },
{ id: 'uploaded', label: 'Uploaded, not checked', look: 'partial' },
{ id: 'unreceived', label: 'Not received', look: 'empty' },
];
const R = 'reconciled';
const U = 'uploaded';
const N = 'unreceived';
export default function MonthGridLooks(): ReactElement {
return (
// The grid takes no width from its months, so a holder sized by its content, as this card is here, gives it one.
<Card style={{ width: '100%', maxWidth: '40rem' }}>
<MonthGrid
label="Savings statements"
states={STATES}
years={[
// Opened in May 2025: January to April are outside the range, and blank.
{ id: 2026, label: '2026', months: [R, R, R, R, R, R, U, U, N, U, null, null] },
{ id: 2025, label: '2025', months: [null, null, null, null, R, R, R, R, R, R, R, R] },
]}
monthLabels={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']}
monthNames={[
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]}
/>
</Card>
);
}Summary and table
With no summary, the grid writes an English sentence from your words. Each state whose look is empty comes first, then each whose look is partial, in the order of states: its label, then its months oldest first, whatever order your rows are drawn in, "Missing: November 2024, March and April 2025. Partial: June 2025." Three or more months in a row are a range, "March to May 2025", and a whole year is "every month of 2025". With nothing missing or partial it names your filled states: "Held: every month." when only one is held, and when another is held too, such as Closed, "Closed: January to March 2025. Held: all other months." Years are put oldest first by their ids when every id is a number; give each year its number as its id, or the summary reads them in the order drawn. In any other language, pass your own summary: list joining and word order are the language's. summary={false} draws none, when the sentence is said elsewhere on the page.
The visible grid is hidden from screen readers. They read the summary, and a table clipped from sight: a caption with the grid's name, a column header per month, a row header per year and your state's word in each cell, so a reader can move to March 2025 by row and column. labels gives the table's words for its first column, "Year", and for a month outside the range, "Not in range".
Calendars
The grid knows rows and columns, not dates. On a Persian page, pass Persian years, the twelve Persian month names and their short labels, and your data already placed in Persian months, with your own summary and table words. The columns follow the page's direction: the year column and Farvardin are at the right.
Example
Example
ناموجود: خرداد و تیر ۱۴۰۴. ناقص: شهریور ۱۴۰۴.
| سال | فروردین | اردیبهشت | خرداد | تیر | مرداد | شهریور | مهر | آبان | آذر | دی | بهمن | اسفند |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ۱۴۰۴ | موجود | موجود | ناموجود | ناموجود | موجود | ناقص | موجود | موجود | موجود | موجود | موجود | موجود |
| ۱۴۰۳ | خارج از بازه | خارج از بازه | خارج از بازه | موجود | موجود | موجود | موجود | موجود | موجود | موجود | موجود | موجود |
import { Card, MonthGrid, type MonthGridState, type MonthGridYear } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const STATES: MonthGridState[] = [
{ id: 'held', label: 'موجود', look: 'filled' },
{ id: 'partial', label: 'ناقص', look: 'partial' },
{ id: 'missing', label: 'ناموجود', look: 'empty' },
];
const H = 'held';
const P = 'partial';
const M = 'missing';
/**
* Placed in Persian years and months by the app, as the code under this example shows: 1404 with Khordad and Tir
* missing and Shahrivar partial, and 1403 from Tir, when the account opened.
*/
const YEARS: MonthGridYear[] = [
{ id: 1404, label: '۱۴۰۴', months: [H, H, M, M, H, P, H, H, H, H, H, H] },
{ id: 1403, label: '۱۴۰۳', months: [null, null, null, H, H, H, H, H, H, H, H, H] },
];
export default function MonthGridPersian(): ReactElement {
return (
// The grid takes no width from its months, so a holder sized by its content, as this card is here, gives it one.
<Card dir="rtl" lang="fa" style={{ width: '100%', maxWidth: '40rem' }}>
<MonthGrid
label="صورتحسابها"
states={STATES}
years={YEARS}
monthLabels={['فر', 'ار', 'خر', 'تی', 'مر', 'شه', 'مه', 'آب', 'آذ', 'دی', 'به', 'اس']}
monthNames={[
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
]}
// The English default does not speak Persian: the app writes its own sentence and table words.
summary="ناموجود: خرداد و تیر ۱۴۰۴. ناقص: شهریور ۱۴۰۴."
labels={{ year: 'سال', outside: 'خارج از بازه' }}
/>
</Card>
);
}Your app places each statement in its Persian year and month with @internationalized/date, which the library does not need:
import { PersianCalendar, toCalendar, type CalendarDate } from '@internationalized/date';
import type { MonthGridYear } from '@snapshot-ui/react';
const persian = new PersianCalendar();
const DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const persianDigits = (value: number): string =>
String(value).replace(/\d/g, (digit) => DIGITS[Number(digit)] ?? digit);
const blankYear = (): (string | null)[] => Array.from({ length: 12 }, () => null);
/**
* Every month from the account's opening to today is missing until a statement says otherwise; the months
* outside that range stay null. Each statement is placed by its date in its Persian year and month.
*/
export function placeInPersian(
statements: readonly { date: CalendarDate; state: string }[],
opened: CalendarDate,
today: CalendarDate,
): MonthGridYear[] {
const years = new Map<number, (string | null)[]>();
const end = toCalendar(today, persian);
for (
let month = toCalendar(opened, persian).set({ day: 1 });
month.compare(end) <= 0;
month = month.add({ months: 1 })
) {
const row = years.get(month.year) ?? blankYear();
row[month.month - 1] = 'missing';
years.set(month.year, row);
}
for (const { date, state } of statements) {
const { year, month } = toCalendar(date, persian);
const row = years.get(year);
if (row) row[month - 1] = state;
}
// Newest first.
return [...years.entries()]
.sort(([a], [b]) => b - a)
.map(([year, months]) => ({ id: year, label: persianDigits(year), months }));
}The Persian calendar, like the Gregorian, has twelve months every year, so every row has the same columns. A calendar whose years differ in their count of months, such as the Hebrew calendar's leap years of thirteen, is not supported.
Usage
The grid is static: a server component renders it with no client JavaScript, and nothing in it computes a date, reads the locale or formats a number. Every year, month name and word is yours.
statesnames your states and maps each tofilled,partialorempty.yearsare the rows in the order drawn, newest first or oldest first; each holdsmonths, a state's id per month ornulloutside the range.monthLabelshead the columns and set their count;monthNamesare read in the summary and the table. Give both the same count, twelve in most calendars. Under 28rem of the grid's own width only every third label shows, the first month of each quarter, so keep them to three letters or fewer: "Jan", "Apr", "Jul", "Oct".- Never draw a state in a color. To put a missing month in front of the reader, say so in your
summary, or with a Badge beside the grid. - The grid takes the width of its box, and its cells stretch with it, about 13px wide in a card on a 320px phone. Under 12rem of its width, such as two cards side by side on a phone, it wraps rather than shrinks: each year's label takes its own line and its months follow in two rows of six, with no month labels. It has no width of its own: in a flex row or a grid column sized to its content, give it a width, or it collapses. In a
flex: 1item or a1frtrack it fits.
import { MonthGrid } from '@snapshot-ui/react';
<MonthGrid
label="Statement coverage"
states={[
{ id: 'held', label: 'Held', look: 'filled' },
{ id: 'partial', label: 'Partial', look: 'partial' },
{ id: 'missing', label: 'Missing', look: 'empty' },
]}
years={[
{
id: 2025,
label: '2025',
months: ['held', 'held', 'missing', 'missing', 'held', 'partial', 'held', 'held', 'held', 'held', null, null],
},
]}
monthLabels={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']}
monthNames={[
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]}
/>;Props
MonthGrid accepts every HTML element attribute, on its figure.
className string | |
|---|---|
hasLegend boolean | The legend of the states under the grid, each state some month holds. Default: true |
label ReactNode | The visible name: "Statement coverage". Without it, |
labels Partial<MonthGridLabels> | The hidden table's words for its first column and a month outside the range. |
monthLabels readonly string[] | The short month labels over the columns, in the app's calendar: "J", "F", or "فر", "ار". Their count sets the columns, 12 for most calendars. Under 28rem of the grid's width only every third, from the first, shows. |
monthNames readonly string[] | The full month names, for the summary and the table: "January", or "فروردین". Same count as |
states readonly MonthGridState[] | The app's states, each mapped to a look. Two states may share a look. |
summary ReactNode | The sentence under the grid, which also describes the figure. A string or node replaces the library's English
default, which names the months of every state whose look is not |
years readonly MonthGridYear[] | The rows, in the order drawn: newest first or oldest first is the app's choice. |
id string | The app's key for the state, used in |
|---|---|
label string | The app's word for it, in the legend, the summary and the table: "Held". |
look MonthGridLook |
|
id Key | Stable across updates. |
|---|---|
label string | The year as the app writes it, in its calendar and digits: "2025", "۱۴۰۴". |
months readonly (string | null)[] | One entry per month, in the order of |
outside string | The table's word for a month outside the range. Default: 'Not in range' |
|---|---|
year string | The table's first column header. Default: 'Year' |
Accessibility
The grid is a figure named by its label, aria-label or aria-labelledby, and described by its summary. The visible label is hidden from screen readers, since the figure's name says it.
- In reading order the summary comes before the table: the gist first, then the detail.
- The table is clipped from sight, not hidden: a screen reader finds it in browse mode and by table navigation, and reads each month by its row and column headers. Its caption is the grid's name.
- The visible grid and the legend are hidden from screen readers, since the table names every month and every state, and the same said twice is noise.
- It takes no focus and has no keys: no cell is a stop and none has a tooltip. To open a month, link to it from your own list.
- A month's state is never color alone: it has a shape, a word in the table and, if it is not filled, a place in the summary. In forced colors the three looks stay a solid, a striped and an outlined cell.
Design rules
What keeps month grids in the system.
Keep the month labels short: a letter, or three.
Lead the summary with what is missing.
Map each state to its own look.
Never draw a missing month in a warning or error color. Say it in words.
Never use more than a few states. Two states with one look read as one.
Never mark a month outside the range as missing. Pass null.