Selection tables
Five tables with real row selection state: a checkbox column, single-select radios, whole-row click, a select-all header that reports an indeterminate partial selection, and a strong highlight treatment. Every control carries an sr-only label and an extended hit area.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/table-selectionDependencies, the @ai2/tokens theme and the component file are installed together.
Copy the source
components/ui/table-selection.tsx"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/* Selection table family: 5 tables with real working row selection. The selection
state is held in React state and the rows are GENUINELY marked: checkbox, radio
(single select), row click, select-all (an indeterminate header) and a highlighted
selection. The boxes have sr-only labels and the ids are produced with
React.useId(). Controls under 24px gain a touch area with after:-inset-1.5. Color
comes ONLY from semantic tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Column = { key: string; label: React.ReactNode }
type Row = Record<string, React.ReactNode>
type TableProps = {
className?: string
size?: StyledSize
columns?: Column[]
rows?: Row[]
}
/* A sensible default data set so it renders without props. */
const defaultColumns: Column[] = [
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
{ key: "status", label: "Status" },
]
const defaultRows: Row[] = [
{ name: "Ada Lovelace", role: "Engineer", status: "Active" },
{ name: "Alan Turing", role: "Researcher", status: "Active" },
{ name: "Grace Hopper", role: "Architect", status: "Away" },
{ name: "Katherine Johnson", role: "Analyst", status: "Active" },
]
/* Hucre yogunlugu: padding + metin olcegi. */
const cell: Record<StyledSize, string> = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-3 py-2 text-sm",
lg: "px-4 py-2.5 text-sm",
xl: "px-5 py-3 text-base",
}
const headCell: Record<StyledSize, string> = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-3 py-2 text-xs",
lg: "px-4 py-2.5 text-sm",
xl: "px-5 py-3 text-sm",
}
const tableBase = "w-full border-collapse text-left align-middle text-foreground"
const headBase = "font-medium text-muted-foreground"
/* An invisible touch-area extension for controls that stay under 24px. */
const control =
"relative size-4 shrink-0 cursor-pointer accent-primary outline-none after:absolute after:-inset-1.5 after:content-[''] focus-visible:ring-[3px] focus-visible:ring-ring/50"
function resolve(columns?: Column[], rows?: Row[]) {
return {
cols: columns && columns.length > 0 ? columns : defaultColumns,
data: rows && rows.length > 0 ? rows : defaultRows,
}
}
/* Satir etiketi: ilk kolonun metni, degilse sira numarasi. */
function rowLabel(row: Row, cols: Column[], i: number): string {
const v = row[cols[0]?.key ?? ""]
if (typeof v === "string") return v
if (typeof v === "number") return String(v)
return `row ${i + 1}`
}
/* Cok secimli durum: indeksler uzerinden Set. */
function useMultiSelection() {
const [selected, setSelected] = React.useState<ReadonlySet<number>>(() => new Set<number>())
const toggle = React.useCallback((i: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(i)) next.delete(i)
else next.add(i)
return next
})
}, [])
const setAll = React.useCallback((indices: number[]) => {
setSelected(new Set(indices))
}, [])
return { selected, toggle, setAll }
}
/* Checkbox: an independent checkbox column on every row. */
export function CheckboxTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { selected, toggle } = useMultiSelection()
const uid = React.useId()
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
<th scope="col" className={cn(headBase, headCell[size], "w-10")}>
<span className="sr-only">Select</span>
</th>
{cols.map((col) => (
<th key={col.key} scope="col" className={cn(headBase, headCell[size])}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const on = selected.has(i)
return (
<tr
key={i}
data-state={on ? "selected" : undefined}
className={cn(
"border-b border-border/60 transition-colors duration-(--motion-fast) ease-(--motion-ease) last:border-b-0",
on && "bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)]"
)}
>
<td className={cell[size]}>
<input
type="checkbox"
id={`${uid}-cb-${i}`}
className={control}
checked={on}
onChange={() => toggle(i)}
/>
<label htmlFor={`${uid}-cb-${i}`} className="sr-only">
{`Select ${rowLabel(row, cols, i)}`}
</label>
</td>
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
/* Radio: one row can be selected, and the radio group covers the whole table. */
export function RadioTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const [selected, setSelected] = React.useState<number | null>(null)
const uid = React.useId()
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
<th scope="col" className={cn(headBase, headCell[size], "w-10")}>
<span className="sr-only">Choose</span>
</th>
{cols.map((col) => (
<th key={col.key} scope="col" className={cn(headBase, headCell[size])}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const on = selected === i
return (
<tr
key={i}
data-state={on ? "selected" : undefined}
className={cn(
"border-b border-border/60 transition-colors duration-(--motion-fast) ease-(--motion-ease) last:border-b-0",
on && "bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)]"
)}
>
<td className={cell[size]}>
<input
type="radio"
name={`${uid}-radio`}
id={`${uid}-rd-${i}`}
className={control}
checked={on}
onChange={() => setSelected(i)}
/>
<label htmlFor={`${uid}-rd-${i}`} className="sr-only">
{`Choose ${rowLabel(row, cols, i)}`}
</label>
</td>
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
/* Row: clicking anywhere in the row changes the selection. */
export function RowTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { selected, toggle } = useMultiSelection()
const uid = React.useId()
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
<th scope="col" className={cn(headBase, headCell[size], "w-10")}>
<span className="sr-only">Select</span>
</th>
{cols.map((col) => (
<th key={col.key} scope="col" className={cn(headBase, headCell[size])}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const on = selected.has(i)
return (
<tr
key={i}
data-state={on ? "selected" : undefined}
onClick={() => toggle(i)}
className={cn(
"cursor-pointer border-b border-border/60 transition-colors duration-(--motion-fast) ease-(--motion-ease) last:border-b-0 hover:bg-[color-mix(in_oklab,var(--color-foreground)_5%,transparent)]",
on && "bg-[color-mix(in_oklab,var(--color-primary)_12%,transparent)]"
)}
>
<td className={cell[size]}>
<input
type="checkbox"
id={`${uid}-row-${i}`}
className={control}
checked={on}
onChange={() => toggle(i)}
onClick={(e) => e.stopPropagation()}
/>
<label htmlFor={`${uid}-row-${i}`} className="sr-only">
{`Select ${rowLabel(row, cols, i)}`}
</label>
</td>
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
/* All: baslikta tumunu sec kutucugu, kismi secimde indeterminate gorunur. */
export function AllTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { selected, toggle, setAll } = useMultiSelection()
const uid = React.useId()
const allRef = React.useRef<HTMLInputElement>(null)
const count = selected.size
const all = count === data.length && data.length > 0
const some = count > 0 && !all
React.useEffect(() => {
if (allRef.current) allRef.current.indeterminate = some
}, [some])
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
<th scope="col" className={cn(headBase, headCell[size], "w-10")}>
<input
ref={allRef}
type="checkbox"
id={`${uid}-all`}
className={control}
checked={all}
onChange={() => setAll(all ? [] : data.map((_, i) => i))}
/>
<label htmlFor={`${uid}-all`} className="sr-only">
Select all rows
</label>
</th>
{cols.map((col) => (
<th key={col.key} scope="col" className={cn(headBase, headCell[size])}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const on = selected.has(i)
return (
<tr
key={i}
data-state={on ? "selected" : undefined}
className={cn(
"border-b border-border/60 transition-colors duration-(--motion-fast) ease-(--motion-ease) last:border-b-0",
on && "bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)]"
)}
>
<td className={cell[size]}>
<input
type="checkbox"
id={`${uid}-all-cb-${i}`}
className={control}
checked={on}
onChange={() => toggle(i)}
/>
<label htmlFor={`${uid}-all-cb-${i}`} className="sr-only">
{`Select ${rowLabel(row, cols, i)}`}
</label>
</td>
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
/* Highlight: the selected row takes a primary tint plus a pronounced strip on its left edge. */
export function HighlightTable({ className, size = "md", columns, rows }: TableProps) {
const { cols, data } = resolve(columns, rows)
const { selected, toggle } = useMultiSelection()
const uid = React.useId()
return (
<div
data-slot="styled-table"
className={cn("w-full overflow-x-auto rounded-lg border border-border", className)}
>
<table className={tableBase}>
<thead>
<tr className="border-b border-border bg-muted">
<th scope="col" className={cn(headBase, headCell[size], "w-10")}>
<span className="sr-only">Select</span>
</th>
{cols.map((col) => (
<th key={col.key} scope="col" className={cn(headBase, headCell[size])}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => {
const on = selected.has(i)
return (
<tr
key={i}
data-state={on ? "selected" : undefined}
className={cn(
"border-b border-border/60 transition-colors duration-(--motion-fast) ease-(--motion-ease) last:border-b-0",
on &&
"bg-[color-mix(in_oklab,var(--color-primary)_16%,transparent)] font-medium text-foreground"
)}
>
<td
className={cn(
cell[size],
"relative",
on && "before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary"
)}
>
<input
type="checkbox"
id={`${uid}-hl-${i}`}
className={control}
checked={on}
onChange={() => toggle(i)}
/>
<label htmlFor={`${uid}-hl-${i}`} className="sr-only">
{`Select ${rowLabel(row, cols, i)}`}
</label>
</td>
{cols.map((col) => (
<td key={col.key} className={cell[size]}>
{row[col.key]}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Checkbox
An independent checkbox column selects rows.
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
import { CheckboxTable } from "@/components/ui/table-selection"
<CheckboxTable />| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
Radio
A radio column allows exactly one selected row.
| Choose | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
import { RadioTable } from "@/components/ui/table-selection"
<RadioTable />| Choose | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Choose | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Choose | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Choose | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
Row
Clicking anywhere on the row toggles its selection.
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
import { RowTable } from "@/components/ui/table-selection"
<RowTable />| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
All
A select-all header checkbox that goes indeterminate on a partial selection.
| Name | Role | Status | |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
import { AllTable } from "@/components/ui/table-selection"
<AllTable />| Name | Role | Status | |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Name | Role | Status | |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Name | Role | Status | |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Name | Role | Status | |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
Highlight
Selected rows get a strong tint and a leading primary rule.
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
import { HighlightTable } from "@/components/ui/table-selection"
<HighlightTable />| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
| Select | Name | Role | Status |
|---|---|---|---|
| Ada Lovelace | Engineer | Active | |
| Alan Turing | Researcher | Active | |
| Grace Hopper | Architect | Away | |
| Katherine Johnson | Analyst | Active |
ai2 Selection tables: 5 styled variations on the token system
The ai2 Selection tables are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around data tables with real, working row selection. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: row selection and hover states run on token CSS transitions. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transitions are disabled and selected rows change instantly.
What is in the ai2 Selection tables?
5 exports in one file: Checkbox, Radio, Row, All and Highlight. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: row selection and hover states run on token CSS transitions.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions are disabled and selected rows change instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Selection tables sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.