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…
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 |
Anatomy: every part, named
Hover a row to locate it in the demo above.
Build prompt
How it behaves right-to-left
This section is ours alone.
Column order
mirrorsColumn 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
mirrorsFreeze 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 mirrorsThe 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
mirrorsDigits 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
mirrorsThe 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.
See also
Updated · 2026-08-19