UIDex

Data Table

جدول البيانات

<table> + <th scope>

also calledtable، HTML table، sortable table، data grid، grid view، tabular list، الجدول، تيبل، داتا تيبل، جريد، شبكة بيانات، جدول قابل للفرز

A data table is a two-axis view: one row is one record, one column is one attribute repeated across every record, and the meaning of a cell comes from where the two meet. It exists for comparison more than for display, so a value the reader needs to weigh against other values belongs in a column, and anything nobody compares belongs in a card or a list. It is not a grid of <div> elements arranged with CSS Grid: that version paints the lines and drops the relationship, and nothing tells a screen reader that 12,400 is the Sales figure for the Cotton Shirt row unless every level carries its role, from role="table" down through rowgroup, row, columnheader, rowheader, and cell. It is not a data grid either, because role="grid" declares a widget with cell-level focus that moves under the arrow keys, and it pulls most screen readers out of browse mode onto a single roving tab stop. It is also not a layout table, the old habit of using <table> to position things on a page, which needs role="presentation" to keep the row and cell semantics out of the accessibility tree. One question settles the choice before you write a line: does the user move between cells with the arrow keys and edit inside them? If not, plain <table> markup is the whole answer, and role="grid" on top of it takes the keyboard behaviour away from the browser without putting anything back.

If you called it…

"the rows and columns thing with the numbers""the list with a header you click to sort""the excel looking part of the page""the column that stays put when I scroll sideways""the top line that sticks while the rest scrolls"

Live specimen

Interact with the demo. Every part is real and numbered.

مبيعات الأسبوع حسب المنتج
المنتجالمنطقةآخر طلب
حقيبة ظهردبي21,900+11.8%2026-08-15
قميص قطنالرياض12,400+4.2%2026-08-12
حذاء رياضيالقاهرة8,150‎-3.1%2026-08-09
الجدول مفروز حسب المبيعات تنازلياً
RTLأول عمود عند اليمين، ويتجمّد هناك بـ inset-inline-start

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build a data table out of real table elements: <table>, <caption>, <thead>, <tbody>. Only reach for <div> markup if every level carries the full role set of role="table", rowgroup, row, columnheader, rowheader, and cell. Make the <caption> the name of the table, name each column with <th scope="col">, and name each record with <th scope="row">. Put a button inside the header cell to sort, set aria-sort on the <th> itself, reset every other column to none in the same update, and announce the result in an aria-live="polite" region. Wrap the table in an overflow-x container carrying tabindex="0", role="region", and a name taken from the caption through aria-labelledby. Freeze the first column with position: sticky plus inset-inline-start: 0, an opaque background, and a z-index; pin the header with inset-block-start: 0; and set border-collapse: separate with border-spacing: 0 so the borders stay attached to their cells. Align numeric columns with text-align: end and font-variant-numeric: tabular-nums, and wrap any signed or percentage value in a <bdi> or an element with dir="ltr". Use logical properties everywhere: padding-inline, margin-inline, border-inline-end, inset-inline-start, text-align: end, and never left, right, padding-left, or text-align: right. Render the empty state inside the table as one row with a cell spanning all columns while the header stays in place, never in a box beside the table and never by removing the table from the page.

How it behaves right-to-left

This section is ours alone.

Column order

mirrors

Column order comes from the direction computed on the table box itself: under RTL the first column in the DOM is drawn at the right and the rest run leftwards. Never reverse the <td> order in markup to reach that picture, because a hand reversal separates each cell from its header and breaks scope while still looking correct. Setting dir on <tbody> or on one cell reorders nothing either, since the order is read from the table, so put the direction on <table> or on something that contains it.

The frozen column

mirrors

Freeze the column with inset-inline-start: 0 rather than left: 0. With left: 0 in Arabic the cell sticks to the left edge while its own column is drawn at the right, and it parks itself on top of other columns. Draw the separator with border-inline-end, and note that box-shadow has no logical form at all: if you use one, sign its offset as calc(6px * var(--dir)) and set --dir to -1 under [dir="rtl"].

The sort arrow

never mirrors

The sort arrow is vertical, up for ascending and down for descending, and both ↑ U+2191 and ↓ U+2193 carry Bidi_Mirrored=No, so neither turns over with the page, and aria-sort does not change meaning either: ascending is smallest first in Arabic exactly as in English. A blanket [dir="rtl"] svg { transform: scaleX(-1) } catches that caret along with every other icon in the header, so scope the flip to a class you put on horizontal icons only. Where the arrow sits inside the header cell does mirror, because it lives at the logical end, so push it with margin-inline-start: auto or justify-content: flex-end in a flex row and never with margin-left.

Numeric columns

mirrors

Digits never flip, since 0 to 9 carry the bidi class EN and render left to right inside an Arabic line; what flips is the alignment of the column, so write text-align: end rather than text-align: right, which pins the figures to the edge Arabic text already starts from and drags them away from their header. The trap is the sign rather than the number: U+002D is bidi class ES, and one that does not fall between two digits resolves to a neutral, takes the paragraph direction, and lands on the far side of the value it belongs to, so 3.1% shows up with its minus stranded. Wrap every signed or percentage value in a <bdi> or in an element with dir="ltr" so the sign stays glued to its number, and add font-variant-numeric: tabular-nums so the digits line up down the column.

Horizontal scrolling

mirrors

The scroll container opens at its inline start, which in Arabic is the right edge, so the first column is on screen with no work from you. Reading that offset back in code is where the engines part: Blink and Gecko report scrollLeft as 0 at that start and run negative from there, while WebKit has long reported a positive value beginning at scrollWidth minus clientWidth on the same edge, so never test scrollLeft against 0 to find where the user stopped, and pass scrollBy a delta signed from getComputedStyle(el).direction === "rtl" ? -1 : 1, since that delta is physical and knows nothing about page direction. Give the container tabindex="0" with role="region" and a name through aria-labelledby: Chrome has made scroll containers keyboard focusable on its own since version 127, but the explicit tabindex is the only part you guarantee yourself, and without it a keyboard user has no way to scroll the table at all.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

HTML<th scope="col"> / <th scope="row">The cheap link between a value and its header; enough until one cell answers to two headers on one axis, where headers and id take over.
HTML<caption>The name of the table, valid only as the first child of <table>.
ARIAaria-sort="ascending | descending | none"Goes on the <th> and not the button, and on one column at a time.
ARIArole="grid"Turns the table into a widget with arrow-key navigation; keep it off a read-only table.
CSSposition: sticky + inset-inline-start: 0Freezes the first column on the correct edge in both directions with no RTL-only rule.
CSSborder-collapse: separate + border-spacing: 0A practical requirement for sticky cells, since collapsed borders belong to the table and scroll away.
Material UI<TableContainer><Table stickyHeader>The container is what scrolls, and stickyHeader works because the table switches to border-collapse: separate.

See also

Updated · 2026-08-19