Skip to content
v1.0.3

Table

A data table — header, sortable columns, hover/selected rows, and numeric (mono, tabular) alignment. Compose it from Table.Head, Table.Body, Table.Row, Table.HeaderCell, and Table.Cell.

In React/Vue, sorting and selection are controlled — you keep the state and pass sortDirection/selected, handling onSort/clicks. In plain HTML, the behavior layer adds client-side sorting and checkbox selection for free.

bash
jlds add table

Usage

html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jarooda/jlds@main/registry/css/index.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jarooda/jlds@main/registry/css/table.css">
<!-- behavior layer: click-to-sort + checkbox selection -->
<script src="https://cdn.jsdelivr.net/gh/jarooda/jlds@main/registry/js/core.js" defer></script>
<script src="https://cdn.jsdelivr.net/gh/jarooda/jlds@main/registry/js/table.js" defer></script>

<div class="jl-table-wrap">
  <table class="jl-table">
    <thead class="jl-table__head">
      <tr>
        <th><input type="checkbox" class="jl-table__check" data-select-all aria-label="Select all"></th>
        <th><button type="button" class="jl-th__btn"><span>Name</span>
          <span class="jl-th__sort"><svg viewBox="0 0 24 24" fill="none"><path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
        </button></th>
        <th class="jl-th--num jl-th--right"><button type="button" class="jl-th__btn"><span>Amount</span>
          <span class="jl-th__sort"><svg viewBox="0 0 24 24" fill="none"><path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
        </button></th>
      </tr>
    </thead>
    <tbody class="jl-table__body">
      <tr>
        <td><input type="checkbox" class="jl-table__check" aria-label="Select row"></td>
        <td>Ada Lovelace</td>
        <td class="jl-td--num jl-td--right">$1,200</td>
      </tr>
      <!-- more rows -->
    </tbody>
  </table>
</div>
vue
<script setup lang="ts">
import { ref, computed } from "vue"
import { Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell } from "@/components/ui/table"

const rows = [
  { id: 1, name: "Ada Lovelace", amount: 1200 },
  { id: 2, name: "Alan Turing", amount: 980 },
  { id: 3, name: "Grace Hopper", amount: 1530 },
]
const sort = ref<{ col: "name" | "amount"; dir: "asc" | "desc" }>({ col: "name", dir: "asc" })
const selected = ref<number[]>([])

const sorted = computed(() =>
  [...rows].sort((a, b) => {
    const r = a[sort.value.col] > b[sort.value.col] ? 1 : -1
    return sort.value.dir === "asc" ? r : -r
  })
)
function toggleSort(col: "name" | "amount") {
  sort.value = { col, dir: sort.value.col === col && sort.value.dir === "asc" ? "desc" : "asc" }
}
const dirFor = (col: string) => (sort.value.col === col ? sort.value.dir : null)
</script>

<template>
  <Table>
    <TableHead>
      <TableRow>
        <TableHeaderCell sortable :sort-direction="dirFor('name')" @sort="toggleSort('name')">Name</TableHeaderCell>
        <TableHeaderCell numeric align="right" sortable :sort-direction="dirFor('amount')" @sort="toggleSort('amount')">Amount</TableHeaderCell>
      </TableRow>
    </TableHead>
    <TableBody>
      <TableRow
        v-for="r in sorted"
        :key="r.id"
        interactive
        :selected="selected.includes(r.id)"
        @click="selected.includes(r.id) ? (selected = selected.filter(i => i !== r.id)) : selected.push(r.id)"
      >
        <TableCell>{{ r.name }}</TableCell>
        <TableCell numeric align="right">${{ r.amount.toLocaleString() }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>
tsx
import { useMemo, useState } from "react"
import { Table } from "@/components/ui/table"

const rows = [
  { id: 1, name: "Ada Lovelace", amount: 1200 },
  { id: 2, name: "Alan Turing", amount: 980 },
  { id: 3, name: "Grace Hopper", amount: 1530 },
]

function Example() {
  const [sort, setSort] = useState<{ col: "name" | "amount"; dir: "asc" | "desc" }>({ col: "name", dir: "asc" })
  const [selected, setSelected] = useState<number[]>([])

  const sorted = useMemo(
    () => [...rows].sort((a, b) => {
      const r = a[sort.col] > b[sort.col] ? 1 : -1
      return sort.dir === "asc" ? r : -r
    }),
    [sort]
  )
  const toggleSort = (col: "name" | "amount") =>
    setSort((s) => ({ col, dir: s.col === col && s.dir === "asc" ? "desc" : "asc" }))
  const dirFor = (col: string) => (sort.col === col ? sort.dir : null)
  const toggleRow = (id: number) =>
    setSelected((s) => (s.includes(id) ? s.filter((i) => i !== id) : [...s, id]))

  return (
    <Table>
      <Table.Head>
        <Table.Row>
          <Table.HeaderCell sortable sortDirection={dirFor("name")} onSort={() => toggleSort("name")}>Name</Table.HeaderCell>
          <Table.HeaderCell numeric align="right" sortable sortDirection={dirFor("amount")} onSort={() => toggleSort("amount")}>Amount</Table.HeaderCell>
        </Table.Row>
      </Table.Head>
      <Table.Body>
        {sorted.map((r) => (
          <Table.Row key={r.id} interactive selected={selected.includes(r.id)} onClick={() => toggleRow(r.id)}>
            <Table.Cell>{r.name}</Table.Cell>
            <Table.Cell numeric align="right">${r.amount.toLocaleString()}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  )
}

Compact & sticky header

density="compact" tightens row padding; stickyHeader pins the header while the body scrolls.

html
<div class="jl-table-wrap">
  <table class="jl-table jl-table--compact">
    <thead class="jl-table__head"><tr><th>User</th><th>Role</th><th class="jl-th--right">Seats</th></tr></thead>
    <tbody class="jl-table__body">
      <tr><td>Ada</td><td>Owner</td><td class="jl-td--num jl-td--right">5</td></tr>
    </tbody>
  </table>
</div>
vue
<template>
  <Table density="compact" sticky-header>…</Table>
</template>
tsx
<Table density="compact" stickyHeader>…</Table>

Props

React

ComponentPropTypeDefaultDescription
Tabledensity"comfortable" | "compact""comfortable"Row padding
TablestickyHeaderbooleanfalsePin header on scroll
Table.RowselectedbooleanfalseSelected highlight
Table.RowinteractivebooleanfalsePointer + hover highlight
Table.HeaderCellalign"left" | "center" | "right""left"Text alignment
Table.HeaderCellnumericbooleanfalseMono tabular header
Table.HeaderCellsortablebooleanfalseRender a sort affordance
Table.HeaderCellsortDirection"asc" | "desc" | nullnullCurrent sort state
Table.HeaderCellonSort(e) => voidFires on header click
Table.Cellalign / numericAs above, for body cells

Vue

Same options. The compound parts are separate components (Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell); TableHeaderCell emits sort instead of onSort.

CSS classes (HTML)

ClassPurpose
.jl-table-wrapHorizontal-scroll wrapper
.jl-table + --compact / --stickyThe table, density, sticky header
.jl-table__head / __body<thead> / <tbody>
.jl-th__btn + .jl-th__sortSortable header button + sort icon
.jl-th--right / --center / --numHeader alignment / numeric
.jl-td--right / --center / --numCell alignment / numeric
.jl-tr--interactive / --selectedRow hover / selected

HTML behavior layer: a sortable header (a .jl-th__btn) becomes click-to-sort (numeric columns via .jl-th--num); a .jl-table__check checkbox per row toggles .jl-tr--selected, and a header .jl-table__check[data-select-all] toggles all. The table emits jl-table:sort and jl-table:select events.