Online

Grid

Overview

A grid enables users to navigate two-dimensional data or interactive elements using directional arrow keys, Home, End, and Page Up/Down. Grids work for data tables, calendars, spreadsheets, and layout patterns that group related interactive elements.

ts
import {Component} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

interface Cell {
  rowSpan: number;
  colSpan: number;
  emoji: string;
  explode: boolean;
}

const bomb = '💣';
const emojis = ['🥳', '🤩', '🎉', '🚀', '🔥', '💯', '🦄', '🤯', '💖', '✨', bomb];

function randomSpan(): number {
  const spanChanceTable = [...Array(10).fill(1), ...Array(4).fill(2), ...Array(1).fill(3)];
  const randomIndex = Math.floor(Math.random() * spanChanceTable.length);
  return spanChanceTable[randomIndex];
}

function generateValidGrid(rowCount: number, colCount: number): Cell[][] {
  const grid: Cell[][] = [];
  const visitedCoords = new Set<string>();
  for (let r = 0; r < rowCount; r++) {
    const row = [];
    for (let c = 0; c < colCount; c++) {
      if (visitedCoords.has(`${r},${c}`)) {
        continue;
      }

      const rowSpan = Math.min(randomSpan(), rowCount - r);
      const maxColSpan = Math.min(randomSpan(), colCount - c);
      let colSpan = 1;
      while (colSpan < maxColSpan) {
        if (visitedCoords.has(`${r},${c + colSpan}`)) break;
        colSpan += 1;
      }

      const emoji = emojis[Math.floor(Math.random() * emojis.length)];

      row.push({
        rowSpan,
        colSpan,
        emoji,
        explode: emoji === bomb,
      });

      for (let rs = 0; rs < rowSpan; rs++) {
        for (let cs = 0; cs < colSpan; cs++) {
          visitedCoords.add(`${r + rs},${c + cs}`);
        }
      }
    }
    grid.push(row);
  }
  return grid;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  readonly gridData: Cell[][] = generateValidGrid(6, 6);
}
html
<table ngGrid #grid="ngGrid">
  @for (row of gridData; track row) {
    <tr ngGridRow>
      @for (cell of row; track cell) {
        @let flipped = {value: false};
        <td ngGridCell [rowSpan]="cell.rowSpan" [colSpan]="cell.colSpan">
          <button
            ngGridCellWidget
            class="card"
            [class.flipped]="flipped.value"
            (click)="flipped.value = true"
          >
            <div class="card-face card-front">
              <svg viewBox="0 0 222 245" xmlns="http://www.w3.org/2000/svg" class="angular-logo">
                <path class="shield-shape" />
              </svg>
            </div>

            <div class="card-face card-back">
              <div [class.explode]="flipped.value && cell.explode">{{ cell.emoji }}</div>
            </div>
          </button>
        </td>
      }
    </tr>
  }
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);

  --card-shadow: 2px 4px 6px rgba(0, 0, 0, 0.5);
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

[ngGrid] {
  display: table;
  border-spacing: 0.75rem;
}

[ngGridCell] {
  height: 4rem;
  width: 4rem;
  perspective: 1000px;
}

.card {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.3s ease-in-out;
  cursor: pointer;
  border-radius: 0.5rem;
  border: 0.25rem solid #f0f0f0;
  box-shadow: var(--card-shadow);
}

.card.flipped {
  transform: rotateY(180deg);
  cursor: default;
}

.card:not(.flipped):hover,
.card:not(.flipped):focus {
  transform: scale(1.05) translate(-2px, -2px);
}

.card:hover,
.card:focus {
  outline-offset: 2px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

.card-face {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  border-radius: 0.25rem;
}

.card-front {
  background-image: var(--hot-pink-to-electric-violet-radial-gradient);
  color: rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.card-back {
  background: #f0f0f0;
  transform: rotateY(180deg);
}

.explode {
  animation: shake 0.25s 20 linear;
}

@keyframes shake {
  0%,
  100% {
    transform: translate(0, 0) rotate(0deg);
  }
  20% {
    transform: translate(-3px, -1px) rotate(-1deg);
  }
  40% {
    transform: translate(3px, 1px) rotate(1deg);
  }
  60% {
    transform: translate(-3px, 1px) rotate(-1deg);
  }
  80% {
    transform: translate(3px, -1px) rotate(1deg);
  }
}

.angular-logo {
  transform: rotate(-25deg) scale(1.1) translateY(5%);
}

.shield-shape {
  d: path(
    'm 222.077 39.192 l -8.019 125.923 L 137.387 0 l 84.69 39.192 Z m -53.105 162.825 l -57.933 33.056 l -57.934 -33.056 l 11.783 -28.556 h 92.301 l 11.783 28.556 Z M 111.039 62.675 l 30.357 73.803 H 80.681 l 30.358 -73.803 Z M 7.937 165.115 L 0 39.192 L 84.69 0 L 7.937 165.115 Z'
  );
  fill: currentColor;
}

Usage

Grids work well for data or interactive elements organized in rows and columns where users need keyboard navigation in multiple directions.

Use grids when:

  • Building interactive data tables with editable or selectable cells
  • Creating calendars or date pickers
  • Implementing spreadsheet-like interfaces
  • Grouping interactive elements (buttons, checkboxes) to reduce tab stops on a page
  • Building interfaces requiring two-dimensional keyboard navigation

Avoid grids when:

  • Displaying simple read-only tables (use semantic HTML <table> instead)
  • Showing single-column lists (use Listbox instead)
  • Displaying hierarchical data (use Tree instead)
  • Building forms without tabular layout (use standard form controls)

Features

  • Two-dimensional navigation - Arrow keys move between cells in all directions
  • Focus modes - Choose between roving tabindex or activedescendant focus strategies
  • Selection support - Optional cell selection with single or multi-select modes
  • Wrapping behavior - Configure how navigation wraps at grid edges (continuous, loop, or nowrap)
  • Range selection - Select multiple cells with modifier keys or dragging
  • Disabled states - Disable the entire grid or individual cells
  • RTL support - Automatic right-to-left language navigation

Examples

Data table grid

Use a grid for interactive tables where users need to navigate between cells using arrow keys. This example shows a basic data table with keyboard navigation.

ts
import {
  afterRenderEffect,
  Component,
  computed,
  ElementRef,
  signal,
  viewChild,
  WritableSignal,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

type Priority = 'High' | 'Medium' | 'Low';

interface Task {
  taskId: number;
  summary: string;
  priority: Priority;
  assignee: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
  private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');

  readonly allSelected = computed(() => this.data().every((t) => t.selected()));
  readonly partiallySelected = computed(
    () => !this.allSelected() && this.data().some((t) => t.selected()),
  );
  readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
    {
      selected: signal(false),
      taskId: 101,
      summary: 'Create Grid Aria Pattern',
      priority: 'High',
      assignee: 'Cyber Cat',
    },
    {
      selected: signal(false),
      taskId: 102,
      summary: 'Build a Pill List example',
      priority: 'Medium',
      assignee: 'Caffeinated Owl',
    },
    {
      selected: signal(false),
      taskId: 103,
      summary: 'Build a Calendar example',
      priority: 'Medium',
      assignee: 'Copybara',
    },
    {
      selected: signal(false),
      taskId: 104,
      summary: 'Build a Data Table example',
      priority: 'Low',
      assignee: 'Rubber Duck',
    },
    {
      selected: signal(false),
      taskId: 105,
      summary: 'Explore Grid possibilities',
      priority: 'High',
      assignee: '[Your Name Here]',
    },
  ]);

  sortAscending: boolean = true;
  tempInput: string = '';

  constructor() {
    afterRenderEffect(() => {
      this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
    });
  }

  startEdit(
    event: KeyboardEvent | FocusEvent | undefined,
    task: Task,
    inputEl: HTMLInputElement,
  ): void {
    this.tempInput = task.assignee;
    inputEl.focus();

    if (!(event instanceof KeyboardEvent)) return;

    // Start editing with an alphanumeric character.
    if (event.key.length === 1) {
      this.tempInput = event.key;
    }
  }

  onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
    if (widget.isActivated()) return;

    widget.activate();
    setTimeout(() => this.startEdit(undefined, task, inputEl));
  }

  completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
    if (!(event instanceof KeyboardEvent)) {
      return;
    }
    if (event.key === 'Enter') {
      task.assignee = this.tempInput;
    }
  }

  updateSelection(event: Event): void {
    const checked = (event.target as HTMLInputElement).checked;
    this.data().forEach((t) => t.selected.set(checked));
  }

  sortTaskById(): void {
    this.sortAscending = !this.sortAscending;
    if (this.sortAscending) {
      this.data.update((tasks) => tasks.sort((a, b) => a.taskId - b.taskId));
    } else {
      this.data.update((tasks) => tasks.sort((a, b) => b.taskId - a.taskId));
    }
  }
}
html
<table ngGrid class="basic-data-table">
  <thead>
    <tr ngGridRow>
      <th ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select all rows"
          type="checkbox"
          [checked]="allSelected()"
          (change)="updateSelection($event)"
          #headerCheckbox
        />
      </th>
      <th ngGridCell>
        <button
          ngGridCellWidget
          class="sort-button"
          aria-label="Sort by ID"
          (click)="sortTaskById()"
        >
          ID
          <span
            aria-hidden="true"
            class="material-symbols-outlined"
            translate="no"
            aria-hidden="true"
          >
            {{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
          </span>
        </button>
      </th>
      <th ngGridCell>Task</th>
      <th ngGridCell>Priority</th>
      <th ngGridCell>Assignee</th>
    </tr>
  </thead>
  <tbody>
    @for (task of data(); track task.taskId) {
      <tr ngGridRow>
        <td ngGridCell>
          <input
            ngGridCellWidget
            aria-label="Select row {{ $index + 1 }}"
            type="checkbox"
            [(ngModel)]="task.selected"
          />
        </td>
        <td ngGridCell>{{ task.taskId }}</td>
        <td ngGridCell>{{ task.summary }}</td>
        <td ngGridCell>{{ task.priority }}</td>
        <td ngGridCell class="assignee-cell">
          <div
            type="button"
            ngGridCellWidget
            aria-label="edit assignee"
            widgetType="editable"
            (activated)="startEdit($event, task, assigneeInput)"
            (deactivated)="completeEdit($event, task)"
            #widget="ngGridCellWidget"
          >
            <span [class.hidden]="widget.isActivated()">{{ task.assignee }}</span>
            <input
              [class.hidden]="!widget.isActivated()"
              class="assignee-edit-input"
              [(ngModel)]="tempInput"
              #assigneeInput
            />
            <button
              tabindex="-1"
              aria-label="edit assignee"
              class="material-symbols-outlined assignee-edit-button"
              (click)="onClickEdit(widget, task, assigneeInput)"
              [class.hidden]="widget.isActivated()"
              translate="no"
            >
              edit
            </button>
          </div>
        </td>
      </tr>
    }
  </tbody>
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.hidden {
  display: none;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

input[type='checkbox'] {
  accent-color: var(--electric-violet);
  transform: scale(1.3);
  outline: none;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  background-color: var(--septenary-contrast);
  border-spacing: 0;
}

[ngGrid] th,
[ngGrid] td {
  padding: 0.75rem 1rem;
}

thead {
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

tbody {
  background-color: var(--octonary-contrast);
}

tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
  background-color: var(--septenary-contrast);
}

[ngGridCell]:focus-within,
[ngGridCell]:hover {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.sort-button {
  display: flex;
  align-items: center;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 700;
}

.assignee-cell [ngGridCellWidget] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  outline: none;
}

.assignee-edit-button {
  visibility: hidden;
  cursor: pointer;
}

.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
  visibility: initial;
}

.assignee-edit-input {
  outline: none;
  border: none;
  color: var(--full-contrast);
  background-color: var(--page-background);
  font-size: 1rem;
  padding: 0.5rem;
}
ts
import {
  afterRenderEffect,
  Component,
  computed,
  ElementRef,
  signal,
  viewChild,
  WritableSignal,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

type Rank = 'S' | 'A' | 'B' | 'C';

interface Task {
  reward: number;
  target: string;
  rank: Rank;
  hunter: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
  private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');

  readonly allSelected = computed(() => this.data().every((t) => t.selected()));
  readonly partiallySelected = computed(
    () => !this.allSelected() && this.data().some((t) => t.selected()),
  );
  readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
    {
      selected: signal(false),
      reward: 50,
      target: '10 Goblins',
      rank: 'C',
      hunter: 'KB Smasher',
    },
    {
      selected: signal(false),
      reward: 999,
      target: '1 Dragon',
      rank: 'S',
      hunter: 'Donkey',
    },
    {
      selected: signal(false),
      reward: 150,
      target: '2 Trolls',
      rank: 'B',
      hunter: 'Meme Spammer',
    },
    {
      selected: signal(false),
      reward: 500,
      target: '1 Demon',
      rank: 'A',
      hunter: 'Dante',
    },
    {
      selected: signal(false),
      reward: 10,
      target: '5 Slimes',
      rank: 'C',
      hunter: '[Help Wanted]',
    },
  ]);

  sortAscending: boolean = true;
  tempInput: string = '';

  constructor() {
    afterRenderEffect(() => {
      this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
    });
  }

  startEdit(
    event: KeyboardEvent | FocusEvent | undefined,
    task: Task,
    inputEl: HTMLInputElement,
  ): void {
    this.tempInput = task.hunter;
    inputEl.focus();

    if (!(event instanceof KeyboardEvent)) return;

    // Start editing with an alphanumeric character.
    if (event.key.length === 1) {
      this.tempInput = event.key;
    }
  }

  onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
    if (widget.isActivated()) return;

    widget.activate();
    setTimeout(() => this.startEdit(undefined, task, inputEl));
  }

  completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
    if (!(event instanceof KeyboardEvent)) {
      return;
    }
    if (event.key === 'Enter') {
      task.hunter = this.tempInput;
    }
  }

  updateSelection(event: Event): void {
    const checked = (event.target as HTMLInputElement).checked;
    this.data().forEach((t) => t.selected.set(checked));
  }

  sortTaskById(): void {
    this.sortAscending = !this.sortAscending;
    if (this.sortAscending) {
      this.data.update((tasks) => tasks.sort((a, b) => a.reward - b.reward));
    } else {
      this.data.update((tasks) => tasks.sort((a, b) => b.reward - a.reward));
    }
  }
}
html
<table ngGrid class="retro-data-table">
  <thead>
    <tr ngGridRow>
      <th ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select all rows"
          type="checkbox"
          [checked]="allSelected()"
          (change)="updateSelection($event)"
          #headerCheckbox
        />
      </th>
      <th ngGridCell>
        <button
          ngGridCellWidget
          class="sort-button"
          aria-label="Sort by ID"
          (click)="sortTaskById()"
        >
          Reward
          <span
            aria-hidden="true"
            class="material-symbols-outlined"
            translate="no"
            aria-hidden="true"
          >
            {{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
          </span>
        </button>
      </th>
      <th ngGridCell>Target</th>
      <th ngGridCell>Rank</th>
      <th ngGridCell>Hunter</th>
    </tr>
  </thead>
  <tbody>
    @for (task of data(); track task) {
      <tr ngGridRow>
        <td ngGridCell>
          <input
            ngGridCellWidget
            aria-label="Select row {{ $index + 1 }}"
            type="checkbox"
            [(ngModel)]="task.selected"
          />
        </td>
        <td ngGridCell>${{ task.reward }}</td>
        <td ngGridCell>{{ task.target }}</td>
        <td ngGridCell>{{ task.rank }}</td>
        <td ngGridCell class="assignee-cell">
          <div
            type="button"
            ngGridCellWidget
            aria-label="edit hunter"
            widgetType="editable"
            (activated)="startEdit($event, task, assigneeInput)"
            (deactivated)="completeEdit($event, task)"
            #widget="ngGridCellWidget"
          >
            <span [class.hidden]="widget.isActivated()">{{ task.hunter }}</span>
            <input
              [class.hidden]="!widget.isActivated()"
              class="assignee-edit-input"
              [(ngModel)]="tempInput"
              #assigneeInput
            />
            <button
              tabindex="-1"
              aria-label="edit hunter"
              class="material-symbols-outlined assignee-edit-button"
              (click)="onClickEdit(widget, task, assigneeInput)"
              [class.hidden]="widget.isActivated()"
              translate="no"
            >
              edit
            </button>
          </div>
        </td>
      </tr>
    }
  </tbody>
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.hidden {
  display: none;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

input[type='checkbox'] {
  accent-color: var(--hot-pink);
  transform: scale(1.3);
  outline: none;
  cursor: pointer;
}

[ngGrid] {
  border-spacing: 0 0.5rem;
  display: table;
}

[ngGrid] th,
[ngGrid] td {
  padding: 0.5rem 0.75rem;
}

thead {
  background-color: var(--retro-button-color);
  color: var(--retro-button-text-color);
  box-shadow: var(--retro-elevated-shadow);
}

tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
  background-color: var(--septenary-contrast);
}

[ngGridCell]:focus-within,
[ngGridCell]:hover {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.sort-button {
  display: flex;
  align-items: center;
  cursor: pointer;
  font-family: 'Press Start 2P';
  font-size: 1rem;
}

.assignee-cell [ngGridCellWidget] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  outline: none;
}

.assignee-edit-button {
  visibility: hidden;
  cursor: pointer;
}

.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
  visibility: initial;
}

.assignee-edit-input {
  outline: none;
  border: none;
  color: var(--full-contrast);
  background-color: var(--page-background);
  font-size: 1rem;
  padding: 0.5rem;
}

Apply the ngGrid directive to the table element, ngGridRow to each row, and ngGridCell to each cell.

Calendar grid

Calendars are a common use case for grids. This example shows a month view where users navigate dates using arrow keys.

ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar basic-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  padding: 0.5rem;
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--electric-violet);
  color: var(--octonary-contrast);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}
ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar material-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  border-radius: 0.5rem;
  padding: 0.5rem;
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
  border-radius: 50%;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--bright-blue) 60%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--indigo-blue);
  color: var(--octonary-contrast);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  color: var(--secondary-contrast);
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}
ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar retro-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  padding: 0.5rem;
  box-shadow: var(--retro-flat-shadow);
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  font-family: 'Press Start 2P';
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--retro-button-color);
  color: var(--retro-button-text-color);
  box-shadow: var(--retro-clickable-shadow);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  background-image: var(--orange-to-pink-vertical-gradient);
  background-clip: text;
  color: transparent;
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

Users can activate a date by pressing Enter or Space when focused on a cell.

Layout grid

Use a layout grid to group interactive elements and reduce tab stops. This example shows a grid of pill buttons.

ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="basic-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>#{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
  max-width: 400px;
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 1px dotted var(--senary-contrast);
  padding: 0 0.25rem 0 0.75rem;
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  outline: none;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.2rem;
  width: 1.5rem;
  height: 1.5rem;
  margin: 0.25rem;
  border-radius: 50%;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}
ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="material-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
  max-width: 400px;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 1px solid var(--senary-contrast);
  border-radius: 0.5rem;
  padding: 0 0.25rem 0 0.75rem;
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  background-color: var(--senary-contrast);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  background-color: initial;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.2rem;
  width: 1.5rem;
  height: 1.5rem;
  margin: 0.25rem;
  border-radius: 50%;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline: none;
  background-color: var(--septenary-contrast);
}
ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="retro-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>#{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
  --retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
  --retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
  --retro-elevated-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast);
  --retro-flat-shadow:
    4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
    -4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
  --retro-clickable-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-light),
    inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
  --retro-pressed-shadow:
    inset 4px 4px 0px 0px var(--retro-shadow-dark),
    inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
    0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
    0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1rem;
  max-width: 400px;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0 0.25rem 0 0.75rem;
  color: var(--retro-button-text-color);
  background-color: var(--retro-button-color);
  box-shadow: var(--retro-clickable-shadow);
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  outline: none;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.5rem;
  margin: 0.25rem;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline-offset: 8px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

Instead of tabbing through each button, users navigate with arrow keys and only one button receives tab focus.

Selection and focus modes

Enable selection with [enableSelection]="true" and configure how focus and selection interact.

html
<table
  ngGrid
  [enableSelection]="true"
  [selectionMode]="'explicit'"
  [multi]="true"
  [focusMode]="'roving'"
>
  <tr ngGridRow>
    <td ngGridCell>Cell 1</td>
    <td ngGridCell>Cell 2</td>
  </tr>
</table>

Selection modes:

  • follow: Focused cell is automatically selected
  • explicit: Users select cells with Space or click

Focus modes:

  • roving: Focus moves to cells using tabindex (better for simple grids)
  • activedescendant: Focus stays on grid container, aria-activedescendant indicates active cell (better for virtual scrolling)

Testing

Angular Aria provides component harnesses for testing grid components. Here is an example of how to use the harnesses in a component test:

ts
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {GridHarness} from '@angular/aria/grid/testing';
import {MyGridComponent} from './my-grid'; // Your component

describe('MyGridComponent', () => {
  let fixture: ComponentFixture<MyGridComponent>;
  let loader: HarnessLoader;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [MyGridComponent],
    });

    fixture = TestBed.createComponent(MyGridComponent);
    await fixture.whenStable();
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  it('should read cell values and focus cells', async () => {
    const grid = await loader.getHarness(GridHarness);

    // Get all cells text in a 2D array organized by rows
    const cellTexts = await grid.getCellTextByIndex();
    expect(cellTexts).toEqual([
      ['Cell 1.1', 'Cell 1.2'],
      ['Cell 2.1', 'Cell 2.2'],
    ]);

    // Get a specific cell by text
    const cells = await grid.getCells({text: 'Cell 1.1'});
    expect(cells.length).toBe(1);
    const cell = cells[0];

    // Verify cell state
    expect(await cell.isSelected()).toBe(true);
    expect(await cell.isActive()).toBe(true);

    // Focus the cell
    await cell.focus();
    expect(await cell.isFocused()).toBe(true);
  });
});

API reference

For detailed API documentation, inspect the following API references: