Qure UI
Components

Table

Rows and columns, built out of the elements that are already accessible.

AccessionPatientStudyReceivedStatus
ACC-482913Kaur R.CT chest09:12Unread
ACC-482910Prasad M.Chest X-ray09:04In review
ACC-482904Fernandes J.CT head08:51Preliminary
ACC-482891Iyer S.Chest X-ray08:33Signed
import {  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/registry/qure/ui/table'const worklist = [  { accession: 'ACC-482913', patient: 'Kaur R.', study: 'CT chest', received: '09:12', status: 'Unread' },  { accession: 'ACC-482910', patient: 'Prasad M.', study: 'Chest X-ray', received: '09:04', status: 'In review' },  { accession: 'ACC-482904', patient: 'Fernandes J.', study: 'CT head', received: '08:51', status: 'Preliminary' },  { accession: 'ACC-482891', patient: 'Iyer S.', study: 'Chest X-ray', received: '08:33', status: 'Signed' },]export default function TableDemo() {  return (    <Table>      <TableHeader>        <TableRow>          <TableHead>Accession</TableHead>          <TableHead>Patient</TableHead>          <TableHead>Study</TableHead>          <TableHead>Received</TableHead>          <TableHead>Status</TableHead>        </TableRow>      </TableHeader>      <TableBody>        {worklist.map(row => (          <TableRow key={row.accession}>            {/* The cell that names the row is a `th scope="row"`, so a screen                reader reading the Status cell says the accession first. */}            <TableHead scope="row" numeric>{row.accession}</TableHead>            <TableCell>{row.patient}</TableCell>            <TableCell>{row.study}</TableCell>            <TableCell numeric>{row.received}</TableCell>            <TableCell>{row.status}</TableCell>          </TableRow>        ))}      </TableBody>    </Table>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/table.json

Usage

import {
  Table, TableBody, TableCaption, TableCell, TableFooter,
  TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
<Table>
  <TableHeader>
    <TableRow>
      <TableHead>Accession</TableHead>
      <TableHead>Study</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableHead scope="row" numeric>ACC-482913</TableHead>
      <TableCell>CT chest</TableCell>
    </TableRow>
  </TableBody>
</Table>

There is no primitive under this and there does not need to be. <table> is already the accessible thing: a screen reader announces the column header before every cell beneath it, and tracks which row you are in as you move. Nothing built out of divs gets that back without a great deal of ARIA, carefully maintained.

This is a table, not a data grid. If you need sorting, column resizing, virtual rows or keyboard cell navigation, that is a grid — and it wants TanStack Table underneath these elements, not a different set of elements.

Composition

<Table>            {/* owns density */}
  <TableCaption>   {/* the accessible name — first in the markup */}
  <TableHeader>    {/* thead; `sticky` pins it */}
    <TableRow><TableHead>      {/* th scope="col" */}
  <TableBody>
    <TableRow><TableHead scope="row">  {/* the cell that names the row */}
              <TableCell>              {/* td */}
  <TableFooter>    {/* tfoot — totals */}
</Table>

The one piece worth learning is scope="row". The cell that identifies a row — an accession number, a patient — should be a TableHead with scope="row", not a TableCell. Do that and a screen reader reading the Status column says "ACC-482913, Status, Unread" rather than just "Unread", which is the difference between a table you can navigate and a table you have to count your way across.

Row identity and totals

Dose report — ACC-482913, CT abdomen and pelvis, 14 April 2026.
SeriesSlicesCTDIvol (mGy)DLP (mGy·cm)
Scout20.412.6
Non-contrast axial3126.8241.3
Arterial phase2987.1258.9
Delayed1443.296.4
Total756609.2
import {  Table, TableBody, TableCaption, TableCell, TableFooter, TableHead,  TableHeader, TableRow,} from '@/registry/qure/ui/table'const doses = [  { series: 'Scout', slices: 2, ctdi: 0.4, dlp: 12.6 },  { series: 'Non-contrast axial', slices: 312, ctdi: 6.8, dlp: 241.3 },  { series: 'Arterial phase', slices: 298, ctdi: 7.1, dlp: 258.9 },  { series: 'Delayed', slices: 144, ctdi: 3.2, dlp: 96.4 },]const totalSlices = doses.reduce((sum, row) => sum + row.slices, 0)const totalDlp = doses.reduce((sum, row) => sum + row.dlp, 0)export default function TableCaptionExample() {  return (    <Table>      {/* The caption is the table's accessible name and is read before any          cell. It goes first in the markup; the browser puts it above the          header for you. */}      <TableCaption>        Dose report — ACC-482913, CT abdomen and pelvis, 14 April 2026.      </TableCaption>      <TableHeader>        <TableRow>          <TableHead>Series</TableHead>          <TableHead align="end">Slices</TableHead>          <TableHead align="end">CTDI<sub>vol</sub> (mGy)</TableHead>          <TableHead align="end">DLP (mGy·cm)</TableHead>        </TableRow>      </TableHeader>      <TableBody>        {doses.map(row => (          <TableRow key={row.series}>            <TableHead scope="row">{row.series}</TableHead>            <TableCell align="end" numeric>{row.slices}</TableCell>            <TableCell align="end" numeric>{row.ctdi.toFixed(1)}</TableCell>            <TableCell align="end" numeric>{row.dlp.toFixed(1)}</TableCell>          </TableRow>        ))}      </TableBody>      <TableFooter>        <TableRow>          <TableHead scope="row">Total</TableHead>          <TableCell align="end" numeric>{totalSlices}</TableCell>          <TableCell align="end">—</TableCell>          <TableCell align="end" numeric>{totalDlp.toFixed(1)}</TableCell>        </TableRow>      </TableFooter>    </Table>  )}

TableCaption is the table's accessible name and is read before any cell, so it earns its place whenever the table is one of several on a page. Put it first in the markup even though it renders above the header — that is where the element belongs, and the browser positions it.

numeric switches the column to tabular figures so digits line up. Alignment is a separate prop on purpose: an accession number is an identifier and reads from the left, a dose is a quantity and reads from the right.

Density

AccessionStudyReader
ACC-482913CT chestA. Mehta
ACC-482910Chest X-rayS. Iyer
ACC-482904CT headP. Nair
ACC-482891Chest X-rayR. Bhatt

1 of 4 selected

'use client'import * as React from 'react'import { Checkbox } from '@/registry/qure/ui/checkbox'import {  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/registry/qure/ui/table'const rows = [  { accession: 'ACC-482913', study: 'CT chest', reader: 'A. Mehta' },  { accession: 'ACC-482910', study: 'Chest X-ray', reader: 'S. Iyer' },  { accession: 'ACC-482904', study: 'CT head', reader: 'P. Nair' },  { accession: 'ACC-482891', study: 'Chest X-ray', reader: 'R. Bhatt' },]export default function TableSelection() {  const [selected, setSelected] = React.useState<string[]>(['ACC-482910'])  const all = selected.length === rows.length  const some = selected.length > 0 && !all  return (    <div className="flex w-full flex-col gap-3">      <Table density="compact">        <TableHeader>          <TableRow>            <TableHead className="w-8">              <Checkbox                size="sm"                aria-label="Select all studies"                checked={all}                indeterminate={some}                onCheckedChange={checked =>                  setSelected(checked ? rows.map(row => row.accession) : [])                }              />            </TableHead>            <TableHead>Accession</TableHead>            <TableHead>Study</TableHead>            <TableHead>Reader</TableHead>          </TableRow>        </TableHeader>        <TableBody>          {rows.map(row => {            const checked = selected.includes(row.accession)            return (              /* `selected` tints the row. It is not announced — a plain table                 has no ARIA selection — so the checkbox carries the state and                 the tint only reinforces it. */              <TableRow key={row.accession} selected={checked}>                <TableCell>                  <Checkbox                    size="sm"                    aria-label={`Select ${row.accession}`}                    checked={checked}                    onCheckedChange={next =>                      setSelected(prev =>                        next ? [...prev, row.accession] : prev.filter(a => a !== row.accession)                      )                    }                  />                </TableCell>                <TableHead scope="row" numeric>{row.accession}</TableHead>                <TableCell>{row.study}</TableCell>                <TableCell>{row.reader}</TableCell>              </TableRow>            )          })}        </TableBody>      </Table>      <p className="text-muted-foreground text-sm" aria-live="polite">        {selected.length} of {rows.length} selected      </p>    </div>  )}

density="compact" tightens the row height for a worklist, where fitting one more study on screen matters more than air. Leave the default for a report or a summary table that is read rather than scanned.

The same example shows selection. selected on a row is styling only — a plain table has no ARIA selection state — so the checkbox carries the meaning and the tint reinforces it. A row that is only tinted is a row that is not selected as far as assistive technology is concerned.

import { ScrollArea } from '@/registry/qure/ui/scroll-area'import {  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/registry/qure/ui/table'const modalities = ['CT chest', 'Chest X-ray', 'CT head', 'MRI brain', 'US abdomen']const statuses = ['Unread', 'In review', 'Preliminary', 'Signed', 'Addendum']const rows = Array.from({ length: 40 }, (_, i) => ({  accession: `ACC-4828${String(40 - i).padStart(2, '0')}`,  study: modalities[i % modalities.length],  status: statuses[i % statuses.length],  received: `${String(7 + Math.floor(i / 6)).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')}`,}))export default function TableSticky() {  return (    /*      The ScrollArea's viewport is the scroll container, so `sticky` on the      header resolves against it. Wrapping the table in a second overflow div      would silently steal that job and the header would scroll away.    */    <ScrollArea className="h-72 w-full rounded-md border px-4">      <Table density="compact">        <TableHeader sticky>          <TableRow>            <TableHead>Accession</TableHead>            <TableHead>Study</TableHead>            <TableHead>Received</TableHead>            <TableHead>Status</TableHead>          </TableRow>        </TableHeader>        <TableBody>          {rows.map(row => (            <TableRow key={row.accession}>              <TableHead scope="row" numeric>{row.accession}</TableHead>              <TableCell>{row.study}</TableCell>              <TableCell numeric>{row.received}</TableCell>              <TableCell>{row.status}</TableCell>            </TableRow>          ))}        </TableBody>      </Table>    </ScrollArea>  )}

Table renders no scroll container of its own, which is deliberate. Wrapping the table in an overflow div quietly makes that div the containing block for position: sticky, and the header then sticks to a box that is not scrolling — the classic "my sticky thead sits still" bug.

Wrap it in ScrollArea instead and pass sticky to the header. The scroll area's viewport is the thing that moves, so the header resolves against it.

<ScrollArea className="h-72">
  <Table density="compact">
    <TableHeader sticky>…</TableHeader>

With other components

AccessionStudyReaderStatus
ACC-482913CT chestAMA. Mehta Critical
ACC-482910Chest X-raySIS. Iyer Past SLA
ACC-482904CT headPNP. Nair In review
ACC-482891Chest X-rayRBR. Bhatt Signed
import { Avatar, AvatarFallback } from '@/registry/qure/ui/avatar'import { Badge, BadgeDot } from '@/registry/qure/ui/badge'import {  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/registry/qure/ui/table'const rows = [  { accession: 'ACC-482913', study: 'CT chest', reader: 'A. Mehta', initials: 'AM', tone: 'urgent', status: 'Critical' },  { accession: 'ACC-482910', study: 'Chest X-ray', reader: 'S. Iyer', initials: 'SI', tone: 'attention', status: 'Past SLA' },  { accession: 'ACC-482904', study: 'CT head', reader: 'P. Nair', initials: 'PN', tone: 'brand', status: 'In review' },  { accession: 'ACC-482891', study: 'Chest X-ray', reader: 'R. Bhatt', initials: 'RB', tone: 'success', status: 'Signed' },] as constexport default function TableRich() {  return (    <Table>      <TableHeader>        <TableRow>          <TableHead>Accession</TableHead>          <TableHead>Study</TableHead>          <TableHead>Reader</TableHead>          <TableHead>Status</TableHead>        </TableRow>      </TableHeader>      <TableBody>        {rows.map(row => (          <TableRow key={row.accession}>            <TableHead scope="row" numeric>{row.accession}</TableHead>            <TableCell>{row.study}</TableCell>            <TableCell>              <span className="flex items-center gap-2">                <Avatar size="sm">                  <AvatarFallback>{row.initials}</AvatarFallback>                </Avatar>                {row.reader}              </span>            </TableCell>            <TableCell>              {/* Soft, not solid. The design system is explicit: tertiary                  backgrounds for inline status on a white row, so a column of                  them does not turn the worklist into a colour chart. */}              <Badge size="sm" tone={row.tone}><BadgeDot /> {row.status}</Badge>            </TableCell>          </TableRow>        ))}      </TableBody>    </Table>  )}

Status uses a soft Badge rather than a solid one. The design system is explicit about this: tertiary backgrounds for inline status on a white row, so a whole column of them reads as a table rather than as a colour chart.

API Reference

Table

PropTypeDefaultDescription
density"default" | "compact""default"compact tightens row height for a worklist.

TableHeader

PropTypeDefaultDescription
stickybooleanfalsePins the header while the surrounding scroll container moves.

TableRow

PropTypeDefaultDescription
selectedbooleanfalseTints the row. Styling only — pair it with a checkbox.

TableHead and TableCell

PropTypeDefaultDescription
align"start" | "center" | "end""start"Text alignment within the cell.
numericbooleanfalseTabular figures, so digits line up down the column.
scope"col" | "row""col"TableHead only. Use row on the cell that names its row.

TableCaption, TableBody and TableFooter render <caption>, <tbody> and <tfoot> and take the usual props for those elements.

On this page