Skip to content

Table ​

The Table family is a set of unstyled primitives for building accessible tables from slots: Table, TableHeader, TableBody, TableRow, TableHead, TableCell, and TableEmpty. TablePagination is a companion control that pairs with them. For sorting and expanding, use DataTable.

vue
<script setup lang="ts">
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@myghf/ui'

const rows = [
  { id: 1, name: 'Amina Farouk', clinic: 'Cardiology' },
]
</script>

<template>
  <Table striped>
    <TableHeader>
      <TableRow>
        <TableHead>Patient</TableHead>
        <TableHead>Clinic</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow v-for="row in rows" :key="row.id">
        <TableCell>{{ row.name }}</TableCell>
        <TableCell>{{ row.clinic }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

Examples ​

Rows, cells, and zones ​

Compose <thead>/<tbody> through TableHeader/TableBody, and rows/cells through TableRow/TableHead/TableCell. striped shades every even body row, size switches to compact text, contained (default) draws the rounded border, and clickable adds a hover/pointer affordance to a row. Column alignment uses logical start/end.

PatientClinicVisits
Amina FaroukCardiology12
Youssef KamalRadiology4
Layla HassanOncology9

Click a row to select it.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@myghf/ui'

const patients = [
  { id: 1, name: 'Amina Farouk', clinic: 'Cardiology', visits: 12 },
  { id: 2, name: 'Youssef Kamal', clinic: 'Radiology', visits: 4 },
  { id: 3, name: 'Layla Hassan', clinic: 'Oncology', visits: 9 },
]

const selected = ref<number | null>(null)
</script>

<template>
  <div class="space-y-3">
    <Table striped>
      <TableHeader>
        <TableRow>
          <TableHead>Patient</TableHead>
          <TableHead>Clinic</TableHead>
          <TableHead align="end">Visits</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow
          v-for="patient in patients"
          :key="patient.id"
          clickable
          @click="selected = patient.id"
        >
          <TableCell>{{ patient.name }}</TableCell>
          <TableCell class="text-muted">{{ patient.clinic }}</TableCell>
          <TableCell align="end">{{ patient.visits }}</TableCell>
        </TableRow>
      </TableBody>
    </Table>

    <p class="text-sm text-muted">
      {{ selected ? `Selected row #${selected}` : 'Click a row to select it.' }}
    </p>
  </div>
</template>

Pagination ​

TablePagination is controlled through v-model:page-index and v-model:page-size and only needs the total count — slice your data yourself. pageIndex is 0-based.

ReferenceClinic
MYG-001Cardiology
MYG-002Radiology
MYG-003Oncology
MYG-004Cardiology
MYG-005Radiology
Rows per page
1–5 of 23
vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TablePagination,
  TableRow,
} from '@myghf/ui'

const rows = Array.from({ length: 23 }, (_, index) => ({
  id: index + 1,
  ref: `MYG-${String(index + 1).padStart(3, '0')}`,
  clinic: ['Cardiology', 'Radiology', 'Oncology'][index % 3],
}))

const pageIndex = ref(0)
const pageSize = ref(5)

const visible = computed(() =>
  rows.slice(pageIndex.value * pageSize.value, (pageIndex.value + 1) * pageSize.value),
)
</script>

<template>
  <Table>
    <TableHeader>
      <TableRow>
        <TableHead>Reference</TableHead>
        <TableHead>Clinic</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow v-for="row in visible" :key="row.id">
        <TableCell>{{ row.ref }}</TableCell>
        <TableCell class="text-muted">{{ row.clinic }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>

  <TablePagination
    v-model:page-index="pageIndex"
    v-model:page-size="pageSize"
    :total="rows.length"
    :page-size-options="[5, 10, 20]"
  />
</template>

Empty state ​

TableEmpty renders one full-width row that is empty by default; pass columns so its colspan matches the table, and provide a label or the default slot for the message.

ReferenceClinic
MYG-001Cardiology
MYG-002Radiology
vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableEmpty,
  TableHead,
  TableHeader,
  TableRow,
} from '@myghf/ui'

const showEmpty = ref(false)
const rows = [
  { id: 1, ref: 'MYG-001', clinic: 'Cardiology' },
  { id: 2, ref: 'MYG-002', clinic: 'Radiology' },
]
</script>

<template>
  <div class="space-y-3">
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Reference</TableHead>
          <TableHead>Clinic</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <template v-if="showEmpty">
          <TableEmpty :columns="2" label="No referrals yet" />
        </template>
        <TableRow v-for="row in rows" v-else :key="row.id">
          <TableCell>{{ row.ref }}</TableCell>
          <TableCell class="text-muted">{{ row.clinic }}</TableCell>
        </TableRow>
      </TableBody>
    </Table>

    <Button variant="outline" size="sm" @click="showEmpty = !showEmpty">
      {{ showEmpty ? 'Show rows' : 'Show empty state' }}
    </Button>
  </div>
</template>

Props ​

Table ​

PropTypeDefaultDescription
stripedbooleanfalseShades every even body row with bg-surface-muted/40.
size'sm' | 'default''default'sm renders text-xs, default renders text-sm.
containedbooleantrueWraps the table in a rounded border with a surface background.

TableHeader / TableBody ​

No props. They render <thead> and <tbody> respectively.

TableRow ​

PropTypeDefaultDescription
clickablebooleanfalseAdds a pointer cursor and a hover background. The click is not wired for you — attach @click.

TableHead ​

PropTypeDefaultDescription
sortDirection'ascending' | 'descending' | 'none'—Sets aria-sort; 'none' omits it. Visual/AT state only — it does not sort.
widthstring—Applied as the cell's inline width.
align'start' | 'center' | 'end''start'Text alignment using logical properties.

TableCell ​

PropTypeDefaultDescription
align'start' | 'center' | 'end''start'Text alignment using logical properties.
truncateboolean— (falsy)Caps the cell at max-w-0 and truncates overflowing text with an ellipsis.

TableEmpty ​

PropTypeDefaultDescription
columnsnumber— (required)Number of columns to span, so the row lines up with the header.
labelstring—Message rendered when no default slot is provided.

TablePagination ​

PropTypeDefaultDescription
pageIndexnumber— (required)0-based index of the current page. Bind with v-model:page-index.
pageSizenumber— (required)Rows per page. Bind with v-model:page-size.
totalnumber— (required)Total number of rows across all pages.
pageSizeOptionsnumber[][5, 10, 20]Options shown in the rows-per-page select.
rowsPerPageLabelstring'Rows per page'Label beside the page-size select.
ofLabelstring'of'Word between the visible range and the total.
previousLabelstring'Previous page'aria-label of the previous button.
nextLabelstring'Next page'aria-label of the next button.

Events ​

ComponentEventPayloadDescription
TablePaginationupdate:pageIndexnumberRequested page change.
TablePaginationupdate:pageSizenumberRequested page-size change.

Table, TableHeader, TableBody, TableRow, TableHead, TableCell, and TableEmpty declare no custom events. Native listeners fall through to their root element (for example, @click on TableRow).

Slots ​

ComponentSlotDescription
TabledefaultTable content (TableHeader then TableBody).
TableHeaderdefaultHeader rows.
TableBodydefaultBody rows.
TableRowdefaultCells.
TableHeaddefaultHeader cell content.
TableCelldefaultCell content.
TableEmptydefaultOverrides label.

TablePagination renders no slots.

Exposed methods ​

None. No component in the family calls defineExpose.

Accessibility ​

  • Table renders a plain <table>. Attributes fall through, so pass aria-label (or a <caption> as the first child of the default slot) to name the table.
  • TableHead renders <th scope="col"> and writes aria-sort only for ascending / descending. It does not make the header interactive: wrap the label in your own <button> when the column is sortable, or let DataTable do it.
  • TableEmpty spans every column with colspan so the message is announced in context.
  • TablePagination uses a native <select> and real <button>s. The active page button gets aria-current="page", previous/next buttons carry aria-labels, and both are disabled at the first/last page.

Dark mode & RTL ​

  • The contained wrapper uses border-border / bg-surface, the header bg-surface-muted/60, striping bg-surface-muted/40, and rows border-border/60 — all semantic tokens, so light and dark both adapt.
  • Alignment uses text-start / text-end, and cell padding is symmetric (px-3 py-2), so the table mirrors with no overrides when the container is dir="rtl".
  • TablePagination lays out with flex and justify-between; its previous/next chevrons use rtl:rotate-180, so they point the right way in bidi layouts.

Released under the MIT License.