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.
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);
}
<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>
@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;
}
Grids work well for data or interactive elements organized in rows and columns where users need keyboard navigation in multiple directions.
Use grids when:
Avoid grids when:
<table> instead)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.
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));
}
}
}
<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>
@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;
}
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));
}
}
}
<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>
@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.
Calendars are a common use case for grids. This example shows a month view where users navigate dates using arrow keys.
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();
}
}
}
<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>
@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;
}
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();
}
}
}
<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>
@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;
}
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();
}
}
}
<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>
@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.
Use a layout grid to group interactive elements and reduce tab stops. This example shows a grid of pill buttons.
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)]);
}
}
<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>
@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);
}
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)]);
}
}
<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>
@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);
}
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)]);
}
}
<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>
@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.
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 clickFocus 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)The container directive that provides keyboard navigation and focus management for rows and cells.
| 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 |
Represents a row within a grid and serves as a container for grid cells.
| Property | Type | Default | Description |
|---|---|---|---|
rowIndex | number | auto | The index of this row within the grid |
Represents an individual cell within a grid row.
| 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 |
| Property | Type | Description |
|---|---|---|
active | Signal<boolean> | Whether the cell currently has focus |
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/grid