Table
Styled primitives for a native HTML table (rows, heads, cells, caption, footer) inside a horizontally scrolling container. No sorting, selection or pagination.
Code
import { Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@fadymondy/nasaq/web";const rows = [ { key: "MH-728", title: "هيكل التطبيق v2", status: "قيد التنفيذ", hours: "6.5" }, { key: "MH-718", title: "خط إنتاج الرموز", status: "مكتملة", hours: "11.25" },];export function IssuesTable() { return ( <Table> <TableHeader> <TableRow> <TableHead>المفتاح</TableHead> <TableHead>العنوان</TableHead> <TableHead>الحالة</TableHead> <TableHead className="text-end">الساعات</TableHead> </TableRow> </TableHeader> <TableBody> {rows.map((r) => ( <TableRow key={r.key}> <TableCell className="font-mono text-caption text-muted-foreground" dir="ltr">{r.key}</TableCell> <TableCell className="font-medium">{r.title}</TableCell> <TableCell><Badge variant="info">{r.status}</Badge></TableCell> <TableCell className="text-end tabular-nums">{r.hours}</TableCell> </TableRow> ))} </TableBody> </Table> );}Data Display · stable
Live examples and controls: Table in the lab.
Install
npx shadcn@latest add https://docs.nasaqui.com/r/table.jsonThe presentational parts of a data table. Each export is a thin wrapper over the matching HTML element
(table, thead, tbody, tfoot, tr, th, td, caption) with Nasaq row height, borders, hover and
selected states. Table wraps the <table> in a container that scrolls horizontally inside its own box, so
a wide table never breaks the page layout.
There is no state or behaviour here. You render the rows and handle everything else.
When to use
- Showing read-only, row-and-column data: issues, invoices, time entries, members.
- Building your own table when you need full control of the markup.
When not to use
- Interactive data tables (sorting, row selection, pagination, row actions): use
DataTable, the interactive layer. It is being built on top of these primitives. - A short list of items with one line each: use a list of
SidebarItems or plain rows, not a table. - Layout of non-tabular content: use the layout primitives (grid/stack), never a table.
- Nothing to show: render
EmptyStatein place of the table, not an empty body.
Import
import {
Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow,
} from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"Quick start
import { Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@fadymondy/nasaq/web";
const rows = [
{ key: "MH-728", title: "هيكل التطبيق v2", status: "قيد التنفيذ", hours: "6.5" },
{ key: "MH-718", title: "خط إنتاج الرموز", status: "مكتملة", hours: "11.25" },
];
export function IssuesTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>المفتاح</TableHead>
<TableHead>العنوان</TableHead>
<TableHead>الحالة</TableHead>
<TableHead className="text-end">الساعات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.key}>
<TableCell className="font-mono text-caption text-muted-foreground" dir="ltr">{r.key}</TableCell>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell><Badge variant="info">{r.status}</Badge></TableCell>
<TableCell className="text-end tabular-nums">{r.hours}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Anatomy
Table data-slot="table-container" (div, overflow-x-auto)
└─ <table> data-slot="table" (props and className go here)
├─ TableCaption data-slot="table-caption" (caption-bottom)
├─ TableHeader data-slot="table-header" <thead>
│ └─ TableRow data-slot="table-row"
│ └─ TableHead × n data-slot="table-head" <th>
├─ TableBody data-slot="table-body" <tbody>
│ └─ TableRow × n data-slot="table-row"
│ └─ TableCell × n data-slot="table-cell" <td>
└─ TableFooter data-slot="table-footer" <tfoot>API
All parts forward their remaining props to the underlying element and merge className. The style props live
on Table and reach every row and cell through context, so you set them once.
Table prop | Type | Default | Description |
|---|---|---|---|
label? | string | none | aria-label of the scroll region. Localise it. |
density? | TableDensity ("compact" | "default" | "comfortable") | "default" | Cell padding: px-2 py-1, px-4 py-3, px-5 py-4. |
frame? | boolean | false | A rounded border and card background around the table, with a tinted header. |
bordered? | boolean | false | Lines between columns as well as rows. |
striped? | boolean | false | Every other body row tinted. Header and footer rows are never striped. |
hover? | boolean | true | Highlight the row under the pointer. |
A selected row (data-state="selected") always wins over the stripe and the hover tint.
| Export | Element | Props type | Notes |
|---|---|---|---|
Table | div > table | TableProps (ComponentProps<"table"> plus label?: string) | className and all props apply to the <table>, not the container. label becomes the aria-label of the scroll container, which is role="region" and tabIndex={0} (focus ring --nq-focus). Base: w-full caption-bottom border-collapse text-body-sm. |
TableHeader | thead | ComponentProps<"thead"> | Row bottom border; header rows do not highlight on hover. |
TableBody | tbody | ComponentProps<"tbody"> | Last row has no bottom border. |
TableFooter | tfoot | ComponentProps<"tfoot"> | Top border, bg-secondary/50, medium weight. Use for totals. |
TableRow | tr | ComponentProps<"tr"> | Bottom border, hover:bg-nq-hover (unless hover={false}), even:bg-secondary/40 when striped, data-[state=selected]:bg-nq-selected. data-state="selected" also sets aria-selected. |
TableHead | th | ComponentProps<"th"> | Defaults to scope="col" (pass scope="row" for row headers). h-row text-start text-caption font-medium text-muted-foreground, padding from density, no wrap. |
TableCell | td | ComponentProps<"td"> | h-row align-middle, padding from density, no wrap. |
TableCaption | caption | ComponentProps<"caption"> | Rendered below the table. text-caption text-muted-foreground. |
Examples
Numbers, keys and a totals row
Numeric columns are text-end tabular-nums. Identifiers such as issue keys stay LTR in Arabic with dir="ltr".
For numbers inside Arabic sentences use Num from the numeric component.
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@fadymondy/nasaq/web";
export function TimeTable() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>المهمة</TableHead>
<TableHead className="text-end">الساعات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>هيكل التطبيق</TableCell>
<TableCell className="text-end tabular-nums">6.5</TableCell>
</TableRow>
<TableRow>
<TableCell>خط إنتاج الرموز</TableCell>
<TableCell className="text-end tabular-nums">11.25</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>الإجمالي</TableCell>
<TableCell className="text-end tabular-nums">17.75</TableCell>
</TableRow>
</TableFooter>
</Table>
);
}Status column and assignee
Use Status (inline) or Badge (chip) for state, and
Avatar size="xs" for people.
import { Avatar, Status, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@fadymondy/nasaq/web";
export function Assignments() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Assignee</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><Status tone="warning">In review</Status></TableCell>
<TableCell>
<span className="flex items-center gap-2">
<Avatar name="Fady Mondy" size="xs" />
Fady Mondy
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}Selected row and caption
Set data-state="selected" on a TableRow to show the selected background. The state is visual only, so add
aria-selected yourself if the table behaves as a grid.
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@fadymondy/nasaq/web";
export function Members({ selected }: { selected: string }) {
const members = ["Nour Adel", "Mona Hany"];
return (
<Table>
<TableCaption>Team members</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={m} data-state={m === selected ? "selected" : undefined}>
<TableCell>{m}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}Framed, striped and bordered
A table that stands on its own on a busy page can carry its own frame. Stripes help follow a row across a wide table; column lines help when cells are dense numbers.
<Table label="Invoices" frame striped>
…
</Table>
<Table label="Ledger" frame bordered density="compact">
…
</Table>Inside a card with its own actions
When the table shares a card with a header action (as in the App Shell story), the card owns the outer edge.
The default cell padding (px-4) already lines up with the card header, so leave frame off.
import {
Button, Card, CardAction, CardDescription, CardHeader, CardTitle,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@fadymondy/nasaq/web";
export function RecentIssues() {
return (
<Card className="gap-2 pb-1">
<CardHeader>
<CardTitle>أحدث المهام</CardTitle>
<CardDescription>آخر ما تم تحديثه</CardDescription>
<CardAction>
<Button size="sm">عرض الكل</Button>
</CardAction>
</CardHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>العنوان</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>هيكل التطبيق v2</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
);
}Accessibility
The parts are native table elements, so screen readers get table, row and column semantics for free.
| Key | Action |
|---|---|
Tab | Moves through focusable content inside cells (links, buttons). Rows and cells themselves are not focusable. |
- Add a
TableCaption(oraria-labelledbyonTable) so the table has a name, and passlabelto name the scroll region. - Header cells are
<th scope="col">by default. Passscope="row"on row headers. TableRow data-state="selected"setsaria-selectedas well as the highlight.- The scroll container is
role="region"withtabIndex={0}, so keyboard users can focus it and scroll columns that overflow on small screens. Passlabel(localised) so the region has a name. - Localise the caption, header text and any cell labels yourself.
RTL & i18n
- Alignment uses logical values:
TableHeadistext-start. Usetext-end(nevertext-right) for numeric columns, andps-*/pe-*for padding. - Column order follows
dir; the firstTableHeadsits on the inline start. - Identifiers, keys, emails and URLs inside Arabic rows need
dir="ltr". Numbers in sentences useNumfrom the numeric component. - Use
tabular-numson numeric cells so digits align. - The component has no built-in strings.
Styling & tokens
- Row height
h-row, separatorborder-border, hoverbg-nq-hover, selectedbg-nq-selected, footerbg-secondary/50. Header text istext-muted-foreground. - State attribute:
data-state="selected"onTableRow. The<table>carriesdata-density, anddata-frame,data-bordered,data-stripedwhen on. - Target parts with
[data-slot=table-row],[data-slot=table-head],[data-slot=table-cell],[data-slot=table-container]. - Extend through
classNameper part. Do not override colours with raw hex; use the tokens above.
Do / Don't
- Do let a table sit on the surface with a heading above it. The table already has rows, so it does not need a card.
- Do use a card only when the table shares it with actions that belong to it (header menu, "View all").
- Do end-align and tabulate numbers; keep keys and IDs LTR.
- Do pair a state with a label: use
StatusorBadge, never a coloured dot alone. - Don't put a table inside a card that has nothing else to say.
- Don't hand-tune row height or padding per screen. Use
densityinstead. - Don't frame a table that already sits in a card: the card is the frame.
- Don't build sorting, selection or pagination on these primitives when
DataTablecovers it.
Related
Lab
https://docs.nasaqui.com/?path=/docs/components-data-display-table--docs