Display
Table
Data table with sorting, selection and two mobile modes
Preview
| Sizes | ||||
|---|---|---|---|---|
| Accordion | Display | sm · md | 3 | |
| Badge | Display | xs · sm · md | 4 | |
| Breadcrumb | Navigation | sm · md | 3 | |
| Burger | Navigation | sm · md · lg | 5 | |
| Button | Actions | xs · sm · md · lg | 9 | |
| ButtonEmail | Actions | xs · sm · md | 6 | |
| ButtonLink | Actions | xs · sm · md · lg | 5 | |
| ButtonScroll | Actions | md · lg | 3 |
The mobile control decides what the table does when its slot drops below 480 px. With scroll it slides sideways with the first column pinned. With stack every row becomes a card and a dropdown sets the order. Picking one turns Narrow on, since on a wide stage both look the same. On a touch screen hoverable doesn't show, because there is no pointer.
size
mobile
sorting.icon
framed
hoverable
striped
selection
Code
import css from "./Table.module.css";
import { useEffect, useRef, useState } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import { CaretUpIcon } from "@phosphor-icons/react/dist/csr/CaretUp";
import { CaretUpDownIcon } from "@phosphor-icons/react/dist/csr/CaretUpDown";
import Checkbox from "@/components/library/react/Checkbox/Checkbox";
type Size = "sm" | "md";
type Direction = "ascending" | "descending";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TableColumn {
key: string;
label: string;
// Required: numbers align to the end, text to the start; a column has to say which.
align: "start" | "end";
sortable?: boolean;
}
interface TableRow {
id: string;
[key: string]: string | number;
}
// Capabilities travel whole: a sorting without its announcement cannot be written.
interface TableSorting {
key: string;
direction: Direction;
icon: "hover" | "always";
announcement: (label: string, direction: Direction) => string;
onChange: (key: string, direction: Direction) => void;
}
interface TableSelection {
selected: string[];
allAriaLabel: string;
rowAriaLabel: (row: TableRow) => string;
onChange: (selected: string[]) => void;
}
interface TableProps {
size: Size | ResponsiveSize;
columns: TableColumn[];
rows: TableRow[];
// Names the table (visually hidden caption) and its scroll region.
"aria-label": string;
mobile: "scroll" | "stack";
framed?: boolean;
hoverable?: boolean;
striped?: boolean;
sorting?: TableSorting;
selection?: TableSelection;
}
const DIRECTION_ICONS = { ascending: CaretUpIcon, descending: CaretDownIcon };
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Table({
size,
columns,
rows,
"aria-label": ariaLabel,
mobile,
framed,
hoverable,
striped,
sorting,
selection,
}: TableProps) {
const [announcement, setAnnouncement] = useState("");
const [scrollable, setScrollable] = useState(true);
const region = useRef<HTMLDivElement>(null);
const mounted = useRef(false);
// "All" needs rows: every() over an empty list is true and would tick the box.
const picked = rows.filter((row) => selection?.selected.includes(row.id)).length;
const all = rows.length > 0 && picked === rows.length;
const some = picked > 0 && !all;
const sort = (column: TableColumn) => {
if (!sorting) return;
const flip = sorting.key === column.key && sorting.direction === "ascending";
sorting.onChange(column.key, flip ? "descending" : "ascending");
};
// Ids from other pages stay put: the table only knows the rows it was given.
const toggleRow = (id: string) => {
if (!selection) return;
const { selected } = selection;
selection.onChange(
selected.includes(id) ? selected.filter((current) => current !== id) : [...selected, id],
);
};
const toggleAll = () => {
if (!selection) return;
const { selected } = selection;
const ids = rows.map((row) => row.id);
selection.onChange(
picked > 0
? selected.filter((id) => !ids.includes(id))
: [...selected, ...ids],
);
};
// The fields, never the object: the caller rebuilds it on every render.
// A live region born with text is never read, so the first run stays silent.
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
if (!sorting) return;
const sorted = columns.find((column) => column.key === sorting.key);
if (sorted) setAnnouncement(sorting.announcement(sorted.label, sorting.direction));
}, [sorting?.key, sorting?.direction]);
useEffect(() => {
const box = region.current;
if (!box) return;
const observer = new ResizeObserver(() => {
setScrollable(box.scrollWidth > box.clientWidth);
});
[box, ...box.children].forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div
className={`
${css.table}
${getSizeClasses(size)}
${css[mobile]}
${framed ? css.framed : ""}
${hoverable ? css.hoverable : ""}
${striped ? css.striped : ""}
${selection ? css.selectable : ""}
${sorting?.icon === "hover" ? css.icon_hover : ""}
`}
>
<div
ref={region}
className={css.table_region}
role="region"
aria-label={ariaLabel}
tabIndex={scrollable ? 0 : undefined}
>
<table className={css.region_table} role="table">
<caption className={css.table_caption}>{ariaLabel}</caption>
<thead className={css.table_head} role="rowgroup">
<tr role="row">
{selection && (
<th
className={`${css.head_cell} ${css.select}`}
role="columnheader"
scope="col"
>
<Checkbox
size={size}
aria-label={selection.allAriaLabel}
indeterminate={some}
checked={all}
onChange={toggleAll}
/>
</th>
)}
{columns.map((column, index) => {
const direction = sorting?.key === column.key ? sorting.direction : undefined;
const SortIcon = direction ? DIRECTION_ICONS[direction] : CaretUpDownIcon;
return (
<th
key={column.key}
className={`
${css.head_cell}
${index === 0 ? css.first : ""}
${column.align === "end" ? css.end : ""}
`}
role="columnheader"
scope="col"
aria-sort={direction}
>
{sorting && column.sortable ? (
<button
type="button"
className={css.cell_sort}
onClick={() => sort(column)}
>
{column.label}
<SortIcon
className={`${css.sort_icon} ${direction ? "" : css.idle}`}
aria-hidden="true"
/>
</button>
) : (
column.label
)}
</th>
);
})}
</tr>
</thead>
<tbody className={css.table_body} role="rowgroup">
{rows.map((row) => {
const selected = selection?.selected.includes(row.id) ?? false;
return (
<tr
key={row.id}
className={`${css.body_row} ${selected ? css.selected : ""}`}
role="row"
>
{selection && (
<td className={`${css.row_cell} ${css.select}`} role="cell">
<Checkbox
size={size}
aria-label={selection.rowAriaLabel(row)}
checked={selected}
onChange={() => toggleRow(row.id)}
/>
</td>
)}
{columns.map((column, index) =>
index === 0 ? (
<th
key={column.key}
className={`
${css.row_cell}
${css.first}
${column.align === "end" ? css.end : ""}
`}
role="rowheader"
scope="row"
data-label={column.label}
>
{row[column.key]}
</th>
) : (
<td
key={column.key}
className={`${css.row_cell} ${column.align === "end" ? css.end : ""}`}
role="cell"
data-label={column.label}
>
{row[column.key]}
</td>
),
)}
</tr>
);
})}
</tbody>
</table>
</div>
<p className={css.table_status} aria-live="polite">
{announcement}
</p>
</div>
);
}
.table {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
/* Keeps the sticky cells' z-index inside: container-type creates no stacking
context, and loose they cover a popover opened over the table. */
isolation: isolate;
font-family: var(--font-body);
font-size: var(--text-14);
font-variant-numeric: tabular-nums;
color: var(--color-text);
&.sm {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
&.sm_base {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_base {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_mobile {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_tablet {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_desktop {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_wide {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
.table_region {
overflow-x: auto;
/* The box paints nothing until framed: the ring keeps the small radius. */
border-radius: var(--radius-4);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.framed .table_region {
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
/* separate, not collapse: collapsed borders belong to the table and stay
behind when a sticky cell scrolls; here each cell paints its own. */
.region_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table_caption,
.table_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.head_cell,
.row_cell {
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
text-align: start;
white-space: nowrap;
}
.head_cell {
background: var(--color-bg);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
&[aria-sort] {
font-weight: var(--weight-600);
color: var(--color-text);
}
}
/* Opaque on purpose: the sticky cells hide what scrolls beneath them. */
.body_row {
--table-fill: var(--color-bg);
}
.row_cell {
background: var(--table-fill);
font-weight: var(--weight-400);
}
.first {
font-weight: var(--weight-500);
}
.end {
text-align: end;
}
.select {
width: 1%;
padding-inline-end: 0;
}
&.framed .body_row:last-child .row_cell {
border-block-end: 0;
}
.cell_sort {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-inline: -6px;
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font: inherit;
color: inherit;
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.end .cell_sort {
flex-direction: row-reverse;
}
.sort_icon {
flex-shrink: 0;
font-size: 14px;
}
.idle {
opacity: 0.5;
}
&.icon_hover .idle {
opacity: 0;
transition: opacity 0.15s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
@media (hover: hover) {
&.icon_hover .cell_sort:hover .idle {
opacity: 0.5;
}
}
&.icon_hover .cell_sort:focus-visible .idle {
opacity: 0.5;
}
/* No transition: sorting and paging move rows between nodes, and a fade would leave a trail. */
&.striped .body_row:nth-child(even):not(.selected) {
--table-fill: color-mix(in srgb, var(--color-text) 3%, var(--color-bg));
}
@media (hover: hover) {
&.hoverable .body_row:hover:not(.selected) {
--table-fill: var(--color-surface-opaque);
}
}
.body_row.selected {
--table-fill: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
}
&.scroll {
.select,
.first {
position: sticky;
inset-inline-start: 0;
z-index: 1;
}
/* Transparent until something slides under the pinned column, so lighting it never moves a
pixel. */
.table_region {
container-type: scroll-state;
}
.first {
border-inline-end: var(--border-surface) solid transparent;
}
@container scroll-state(scrollable: inline-start) {
.first {
border-inline-end-color: var(--color-border);
}
}
}
/* The selection cell is the Checkbox root (24) plus the start padding. */
&.scroll.selectable .first {
inset-inline-start: calc(24px + var(--table-pad));
}
&.stack {
/* display: none, never visually hidden: hidden sort buttons would stay in the Tab order. */
.table_head {
display: none;
}
.region_table {
display: block;
}
.table_body {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.body_row {
display: block;
position: relative;
padding: var(--space-8) var(--space-12);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
.body_row.selected {
border-color: var(--color-accent);
}
.row_cell {
display: flex;
justify-content: space-between;
gap: var(--space-12);
height: auto;
padding-block: 6px;
padding-inline: 0;
border: 0;
background: none;
white-space: normal;
&::before {
content: attr(data-label);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
}
.first::before,
.select::before {
content: none;
}
/* Off the type scale: a step above the 14 of its lines. With the card's 8 of
padding, 2 more sets this 24 line level with the Checkbox at 10. */
.first {
padding-block-start: 2px;
font-size: 15px;
line-height: 24px;
}
.select {
position: absolute;
inset-inline-start: 10px;
inset-block-start: 10px;
width: auto;
padding: 0;
}
}
/* Room for the checkbox, which sits in the top corner of the card. */
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: 36px;
}
&.framed.stack .table_region {
border: 0;
}
@container (min-width: 480px) {
&.stack {
.table_head {
display: table-header-group;
}
.region_table {
display: table;
}
.table_body {
display: table-row-group;
}
.body_row {
display: table-row;
position: static;
padding: 0;
border: 0;
border-radius: 0;
}
.row_cell {
display: table-cell;
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
background: var(--table-fill);
white-space: nowrap;
&::before {
content: none;
}
}
.first {
font-size: inherit;
line-height: inherit;
}
.select {
position: static;
width: 1%;
padding-block: 0;
padding-inline: var(--table-pad) 0;
}
}
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: var(--table-pad);
}
&.framed.stack .table_region {
border: var(--border-surface) solid var(--color-border-card);
}
}
}
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from "vue";
import { PhCaretDown, PhCaretUp, PhCaretUpDown } from "@phosphor-icons/vue";
import Checkbox from "@/components/library/vue/Checkbox/Checkbox.vue";
type Size = "sm" | "md";
type Direction = "ascending" | "descending";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface TableColumn {
key: string;
label: string;
// Required: numbers align to the end, text to the start; a column has to say which.
align: "start" | "end";
sortable?: boolean;
}
interface TableRow {
id: string;
[key: string]: string | number;
}
// Capabilities travel whole: a sorting without its announcement cannot be written.
interface TableSorting {
key: string;
direction: Direction;
icon: "hover" | "always";
announcement: (label: string, direction: Direction) => string;
onChange: (key: string, direction: Direction) => void;
}
interface TableSelection {
selected: string[];
allAriaLabel: string;
rowAriaLabel: (row: TableRow) => string;
onChange: (selected: string[]) => void;
}
interface TableProps {
size: Size | ResponsiveSize;
columns: TableColumn[];
rows: TableRow[];
// Names the table (visually hidden caption) and its scroll region.
ariaLabel: string;
mobile: "scroll" | "stack";
framed?: boolean;
hoverable?: boolean;
striped?: boolean;
sorting?: TableSorting;
selection?: TableSelection;
}
const { size, columns, rows, sorting, selection } = defineProps<TableProps>();
const announcement = ref("");
const scrollable = ref(true);
const region = useTemplateRef<HTMLDivElement>("region");
let observer: ResizeObserver | undefined;
// "All" needs rows: every() over an empty list is true and would tick the box.
const picked = computed(
() => rows.filter((row) => selection?.selected.includes(row.id)).length,
);
const all = computed(() => rows.length > 0 && picked.value === rows.length);
const some = computed(() => picked.value > 0 && !all.value);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const sort = (column: TableColumn) => {
if (!sorting) return;
const flip = sorting.key === column.key && sorting.direction === "ascending";
sorting.onChange(column.key, flip ? "descending" : "ascending");
};
// Ids from other pages stay put: the table only knows the rows it was given.
const toggleRow = (id: string) => {
if (!selection) return;
const { selected } = selection;
selection.onChange(
selected.includes(id) ? selected.filter((current) => current !== id) : [...selected, id],
);
};
const toggleAll = () => {
if (!selection) return;
const { selected } = selection;
const ids = rows.map((row) => row.id);
selection.onChange(
picked.value > 0
? selected.filter((id) => !ids.includes(id))
: [...selected, ...ids],
);
};
// Two sources, compared field by field: the caller rebuilds the object on every
// render. No immediate: a live region born with text is never read.
watch([() => sorting?.key, () => sorting?.direction], () => {
if (!sorting) return;
const sorted = columns.find((column) => column.key === sorting.key);
if (sorted) announcement.value = sorting.announcement(sorted.label, sorting.direction);
});
onMounted(() => {
const box = region.value;
if (!box) return;
observer = new ResizeObserver(() => {
scrollable.value = box.scrollWidth > box.clientWidth;
});
[box, ...box.children].forEach((element) => observer?.observe(element));
});
onUnmounted(() => observer?.disconnect());
</script>
<template>
<div
class="table"
:class="[
sizeClasses,
mobile,
{
framed,
hoverable,
striped,
selectable: selection,
icon_hover: sorting?.icon === 'hover',
},
]"
>
<div
ref="region"
class="table_region"
role="region"
:aria-label="ariaLabel"
:tabindex="scrollable ? 0 : undefined"
>
<table class="region_table" role="table">
<caption class="table_caption">{{ ariaLabel }}</caption>
<thead class="table_head" role="rowgroup">
<tr role="row">
<th v-if="selection" class="head_cell select" role="columnheader" scope="col">
<Checkbox
:size="size"
:aria-label="selection.allAriaLabel"
:indeterminate="some"
:modelValue="all"
@update:modelValue="toggleAll()"
/>
</th>
<th
v-for="(column, index) in columns"
:key="column.key"
class="head_cell"
:class="{ first: index === 0, end: column.align === 'end' }"
role="columnheader"
scope="col"
:aria-sort="sorting?.key === column.key ? sorting.direction : undefined"
>
<button
v-if="sorting && column.sortable"
type="button"
class="cell_sort"
@click="sort(column)"
>
{{ column.label }}
<PhCaretUpDown
v-if="sorting.key !== column.key"
class="sort_icon idle"
aria-hidden="true"
/>
<PhCaretUp
v-else-if="sorting.direction === 'ascending'"
class="sort_icon"
aria-hidden="true"
/>
<PhCaretDown v-else class="sort_icon" aria-hidden="true" />
</button>
<template v-else>{{ column.label }}</template>
</th>
</tr>
</thead>
<tbody class="table_body" role="rowgroup">
<tr
v-for="row in rows"
:key="row.id"
class="body_row"
:class="{ selected: selection?.selected.includes(row.id) }"
role="row"
>
<td v-if="selection" class="row_cell select" role="cell">
<Checkbox
:size="size"
:aria-label="selection.rowAriaLabel(row)"
:modelValue="selection.selected.includes(row.id)"
@update:modelValue="toggleRow(row.id)"
/>
</td>
<template v-for="(column, index) in columns" :key="column.key">
<th
v-if="index === 0"
class="row_cell first"
:class="{ end: column.align === 'end' }"
role="rowheader"
scope="row"
:data-label="column.label"
>
{{ row[column.key] }}
</th>
<td
v-else
class="row_cell"
:class="{ end: column.align === 'end' }"
role="cell"
:data-label="column.label"
>
{{ row[column.key] }}
</td>
</template>
</tr>
</tbody>
</table>
</div>
<p class="table_status" aria-live="polite">{{ announcement }}</p>
</div>
</template>
<style scoped>
.table {
/* The root is the container its own layout queries. Inline-size containment
drops the content width, so a flex parent would shrink it to 0 without this. */
width: 100%;
container-type: inline-size;
/* Keeps the sticky cells' z-index inside: container-type creates no stacking
context, and loose they cover a popover opened over the table. */
isolation: isolate;
font-family: var(--font-body);
font-size: var(--text-14);
font-variant-numeric: tabular-nums;
color: var(--color-text);
&.sm {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
&.sm_base {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_base {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
@media (min-width: 480px) {
&.sm_mobile {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_mobile {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_tablet {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_desktop {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
--table-row: var(--size-40);
--table-pad: var(--space-12);
}
&.md_wide {
--table-row: var(--size-48);
--table-pad: var(--space-16);
}
}
.table_region {
overflow-x: auto;
/* The box paints nothing until framed: the ring keeps the small radius. */
border-radius: var(--radius-4);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
&.framed .table_region {
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
/* separate, not collapse: collapsed borders belong to the table and stay
behind when a sticky cell scrolls; here each cell paints its own. */
.region_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table_caption,
.table_status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.head_cell,
.row_cell {
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
text-align: start;
white-space: nowrap;
}
.head_cell {
background: var(--color-bg);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
&[aria-sort] {
font-weight: var(--weight-600);
color: var(--color-text);
}
}
/* Opaque on purpose: the sticky cells hide what scrolls beneath them. */
.body_row {
--table-fill: var(--color-bg);
}
.row_cell {
background: var(--table-fill);
font-weight: var(--weight-400);
}
.first {
font-weight: var(--weight-500);
}
.end {
text-align: end;
}
.select {
width: 1%;
padding-inline-end: 0;
}
&.framed .body_row:last-child .row_cell {
border-block-end: 0;
}
.cell_sort {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-inline: -6px;
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font: inherit;
color: inherit;
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.end .cell_sort {
flex-direction: row-reverse;
}
.sort_icon {
flex-shrink: 0;
font-size: 14px;
}
.idle {
opacity: 0.5;
}
&.icon_hover .idle {
opacity: 0;
transition: opacity 0.15s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
@media (hover: hover) {
&.icon_hover .cell_sort:hover .idle {
opacity: 0.5;
}
}
&.icon_hover .cell_sort:focus-visible .idle {
opacity: 0.5;
}
/* No transition: sorting and paging move rows between nodes, and a fade would leave a trail. */
&.striped .body_row:nth-child(even):not(.selected) {
--table-fill: color-mix(in srgb, var(--color-text) 3%, var(--color-bg));
}
@media (hover: hover) {
&.hoverable .body_row:hover:not(.selected) {
--table-fill: var(--color-surface-opaque);
}
}
.body_row.selected {
--table-fill: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg));
}
&.scroll {
.select,
.first {
position: sticky;
inset-inline-start: 0;
z-index: 1;
}
/* Transparent until something slides under the pinned column, so lighting it never moves a
pixel. */
.table_region {
container-type: scroll-state;
}
.first {
border-inline-end: var(--border-surface) solid transparent;
}
@container scroll-state(scrollable: inline-start) {
.first {
border-inline-end-color: var(--color-border);
}
}
}
/* The selection cell is the Checkbox root (24) plus the start padding. */
&.scroll.selectable .first {
inset-inline-start: calc(24px + var(--table-pad));
}
&.stack {
/* display: none, never visually hidden: hidden sort buttons would stay in the Tab order. */
.table_head {
display: none;
}
.region_table {
display: block;
}
.table_body {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.body_row {
display: block;
position: relative;
padding: var(--space-8) var(--space-12);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-12);
}
.body_row.selected {
border-color: var(--color-accent);
}
.row_cell {
display: flex;
justify-content: space-between;
gap: var(--space-12);
height: auto;
padding-block: 6px;
padding-inline: 0;
border: 0;
background: none;
white-space: normal;
&::before {
content: attr(data-label);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
}
.first::before,
.select::before {
content: none;
}
/* Off the type scale: a step above the 14 of its lines. With the card's 8 of
padding, 2 more sets this 24 line level with the Checkbox at 10. */
.first {
padding-block-start: 2px;
font-size: 15px;
line-height: 24px;
}
.select {
position: absolute;
inset-inline-start: 10px;
inset-block-start: 10px;
width: auto;
padding: 0;
}
}
/* Room for the checkbox, which sits in the top corner of the card. */
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: 36px;
}
&.framed.stack .table_region {
border: 0;
}
@container (min-width: 480px) {
&.stack {
.table_head {
display: table-header-group;
}
.region_table {
display: table;
}
.table_body {
display: table-row-group;
}
.body_row {
display: table-row;
position: static;
padding: 0;
border: 0;
border-radius: 0;
}
.row_cell {
display: table-cell;
height: var(--table-row);
padding: 0 var(--table-pad);
border-block-end: var(--border-surface) solid var(--color-border);
background: var(--table-fill);
white-space: nowrap;
&::before {
content: none;
}
}
.first {
font-size: inherit;
line-height: inherit;
}
.select {
position: static;
width: 1%;
padding-block: 0;
padding-inline: var(--table-pad) 0;
}
}
&.stack.selectable .row_cell:not(.select) {
padding-inline-start: var(--table-pad);
}
&.framed.stack .table_region {
border: var(--border-surface) solid var(--color-border-card);
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | ResponsiveSize | required | Height of each row (40/48) and its side padding (12/16). The text stays at 14 and the selection Checkbox takes the same size. Fixed or responsive. |
| columns | { key: string; label: string; align: "start" | "end"; sortable?: boolean }[] | required | Columns in order. The first one heads each row. align is required, because numbers go to the end and text to the start. |
| rows | { id: string; [key: string]: string | number }[] | required | Rows already filtered, sorted and paged by the caller. Each cell paints row[column.key] as is; id identifies the row in the selection. |
| aria-label | string | required | Name of the table and of its scroll region for screen readers. |
| mobile | "scroll" | "stack" | required | What it does when narrow. scroll slides sideways with the first column pinned; stack turns each row into a card below a 480 px container, without the header. |
| framed | boolean | — | Card contour around the table. |
| hoverable | boolean | — | Tints the row under the pointer. The selected one does not change. |
| striped | boolean | — | Tints every other row. |
| sorting | { key: string; direction: "ascending" | "descending"; icon: "hover" | "always"; announcement: (label, direction) => string; onChange: (key, direction) => void } | — | Turns sorting on whole or not at all: state, icon, the text that announces each change and the callback. Without it, headers are plain text. |
| selection | { selected: string[]; allAriaLabel: string; rowAriaLabel: (row) => string; onChange: (selected) => void } | — | Turns the checkbox column on whole or not at all: the checked ids, the names for screen readers and the callback. "All" means the rows received; ids from other pages are kept. With any checked, the header box clears them. |