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 { 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.component.html', styleUrl: 'app.component.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"> {{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" (onActivate)="startEdit($event, task, assigneeInput)" (onDeactivate)="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()" > 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] { 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;}
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.component.html', styleUrl: 'app.component.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"> {{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" (onActivate)="startEdit($event, task, assigneeInput)" (onDeactivate)="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()" > 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] { 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.component.html', styleUrl: 'app.component.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"> {{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" (onActivate)="startEdit($event, task, assigneeInput)" (onDeactivate)="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()" > 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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.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;}[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.component.html', styleUrl: 'app.component.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">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">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] { 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.component.html', styleUrl: 'app.component.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">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">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] { 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.component.html', styleUrl: 'app.component.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">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">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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.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] { 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.component.html', styleUrl: 'app.component.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)" > 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.component.html', styleUrl: 'app.component.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)" > 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.component.html', styleUrl: 'app.component.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)" > 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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}[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.
<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 selectedexplicit: Users select cells with Space or click
Focus modes:
roving: Focus moves to cells usingtabindex(better for simple grids)activedescendant: Focus stays on grid container,aria-activedescendantindicates active cell (better for virtual scrolling)
APIs
Grid
The container directive that provides keyboard navigation and focus management for rows and cells.
Inputs
| Property | Type | Default | Description |
|---|---|---|---|
enableSelection |
boolean |
false |
Whether selection is enabled for the grid |
disabled |
boolean |
false |
Disables the entire grid |
softDisabled |
boolean |
true |
When true, disabled cells are focusable but not interactive |
focusMode |
'roving' | 'activedescendant' |
'roving' |
Focus strategy used by the grid |
rowWrap |
'continuous' | 'loop' | 'nowrap' |
'loop' |
Navigation wrapping behavior along rows |
colWrap |
'continuous' | 'loop' | 'nowrap' |
'loop' |
Navigation wrapping behavior along columns |
multi |
boolean |
false |
Whether multiple cells can be selected |
selectionMode |
'follow' | 'explicit' |
'follow' |
Whether selection follows focus or requires explicit action |
enableRangeSelection |
boolean |
false |
Enable range selections with modifier keys or dragging |
GridRow
Represents a row within a grid and serves as a container for grid cells.
Inputs
| Property | Type | Default | Description |
|---|---|---|---|
rowIndex |
number |
auto | The index of this row within the grid |
GridCell
Represents an individual cell within a grid row.
Inputs
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | Unique identifier for the cell |
role |
string |
'gridcell' |
Cell role: gridcell, columnheader, or rowheader |
disabled |
boolean |
false |
Disables this cell |
selected |
boolean |
false |
Whether the cell is selected (supports two-way binding) |
selectable |
boolean |
true |
Whether the cell can be selected |
rowSpan |
number |
— | Number of rows the cell spans |
colSpan |
number |
— | Number of columns the cell spans |
rowIndex |
number |
— | Row index of the cell |
colIndex |
number |
— | Column index of the cell |
orientation |
'vertical' | 'horizontal' |
'horizontal' |
Orientation for widgets within the cell |
wrap |
boolean |
true |
Whether widget navigation wraps within the cell |
Signals
| Property | Type | Description |
|---|---|---|
active |
Signal<boolean> |
Whether the cell currently has focus |