Datagrid
A data table built with CSS Grid and Subgrid. Every row stays in the DOM for native table navigation, browser search, and text selection.
Grouped and pinned columns
Pass nested
columnDefsusing
childrenfor groups. Each leaf's
sizeis one CSS Grid track size: lengths, percentages,
fr,
auto,
min-content,
max-content,
minmax(), or
fit-content(). The default is
minmax(0, 1fr). Track lists such as
repeat()belong in separate column definitions.
Set
pin: "left"or
pin: "right"on a column or group. Pinned columns move to their edge, preserving order
within each region. Groups split at region boundaries. Scroll horizontally
below to keep ID, name, and salary visible. Measured sticky offsets follow
changes in content, fonts, and available width.
| ID | Person | Person | Work | Work | |
|---|---|---|---|---|---|
| Name | Contact | Team | Salary | ||
| City | |||||
| 1 | Alex Morgan | alex@example.com | Lisbon | Design | €82,000 |
| 2 | Sam Rivera | sam@example.com | London | Engineering | €96,000 |
| 3 | Jamie Chen | jamie@example.com | Berlin | Design | €88,000 |
| 4 | Taylor Reed | taylor@example.com | Madrid | Operations | €76,000 |
import { Datagrid, type DatagridColumnDef } from "@octaneui/core";
interface Person {
id: number;
name: string;
email: string;
team: string;
city: string;
salary: number;
}
const people: Person[] = [
{
id: 1,
name: "Alex Morgan",
email: "alex@example.com",
team: "Design",
city: "Lisbon",
salary: 82000,
},
{
id: 2,
name: "Sam Rivera",
email: "sam@example.com",
team: "Engineering",
city: "London",
salary: 96000,
},
{
id: 3,
name: "Jamie Chen",
email: "jamie@example.com",
team: "Design",
city: "Berlin",
salary: 88000,
},
{
id: 4,
name: "Taylor Reed",
email: "taylor@example.com",
team: "Operations",
city: "Madrid",
salary: 76000,
},
];
const columns: DatagridColumnDef<Person>[] = [
{ id: "id", header: "ID", value: (row) => row.id, size: "4rem", pin: "left" },
{
id: "person",
header: "Person",
children: [
{
id: "name",
header: "Name",
value: (row) => row.name,
size: "minmax(10rem, 1fr)",
pin: "left",
},
{
id: "contact",
header: "Contact",
children: [
{
id: "email",
header: "Email",
value: (row) => row.email,
size: "max-content",
cell: ({ row }) => <a href={`mailto:${row.email}`}>{row.email}</a>,
},
{
id: "city",
header: "City",
value: (row) => row.city,
size: "fit-content(12rem)",
},
],
},
],
},
{
id: "work",
header: "Work",
children: [
{
id: "team",
header: "Team",
value: (row) => row.team,
size: "minmax(10rem, 2fr)",
},
{
id: "salary",
header: "Salary",
value: (row) => row.salary,
size: "7rem",
align: "end",
pin: "right",
cell: ({ row }) => `€${row.salary.toLocaleString("en-IE")}`,
},
],
},
];
export function GroupedDatagridExample() @{
<Datagrid
caption="People directory"
columnDefs={columns}
data={people}
getRowId={(row) => row.id}
/>
}Controlled data
Update
datato filter, sort, paginate, or display fetched results. This example
filters, sorts, and paginates outside the component. Supply a stable,
unique
getRowIdso custom cells retain their identity.
| Name | Team | City |
|---|---|---|
| Alex Morgan | Design | Lisbon |
| Avery Shah | Engineering | Amsterdam |
| Casey Bloom | Design | Paris |
import { useState } from "octane";
import {
Button,
Datagrid,
Input,
Pagination,
type DatagridColumnDef,
} from "@octaneui/core";
interface Person {
id: number;
name: string;
team: string;
city: string;
}
const people: Person[] = [
{ id: 1, name: "Alex Morgan", team: "Design", city: "Lisbon" },
{ id: 2, name: "Sam Rivera", team: "Engineering", city: "London" },
{ id: 3, name: "Jamie Chen", team: "Design", city: "Berlin" },
{ id: 4, name: "Taylor Reed", team: "Operations", city: "Madrid" },
{ id: 5, name: "Riley Patel", team: "Engineering", city: "Dublin" },
{ id: 6, name: "Casey Bloom", team: "Design", city: "Paris" },
{ id: 7, name: "Morgan Lee", team: "Operations", city: "Vienna" },
{ id: 8, name: "Avery Shah", team: "Engineering", city: "Amsterdam" },
{ id: 9, name: "Quinn Park", team: "Design", city: "Oslo" },
{ id: 10, name: "Jordan Cole", team: "Operations", city: "Rome" },
];
const columns: DatagridColumnDef<Person>[] = [
{
id: "name",
header: "Name",
value: (row) => row.name,
size: "minmax(8rem, 2fr)",
},
{
id: "team",
header: "Team",
value: (row) => row.team,
size: "minmax(7rem, 1fr)",
},
{ id: "city", header: "City", value: (row) => row.city, size: "25%" },
];
const PAGE_SIZE = 3;
export function ControlledDatagridExample() @{
const [query, setQuery] = useState("");
const [descending, setDescending] = useState(false);
const [page, setPage] = useState(1);
const visiblePeople = people.filter(
(person) =>
`${person.name} ${person.team} ${person.city}`.toLowerCase().includes(
query.toLowerCase(),
),
).sort(
(a, b) => descending
? b.name.localeCompare(a.name)
: a.name.localeCompare(b.name),
);
const pageCount = Math.max(1, Math.ceil(visiblePeople.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const pagedPeople = visiblePeople.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
);
<>
<Input
aria-label="Filter people"
placeholder="Filter by name, team, or city"
value={query}
onInput={(event) => {
setQuery(event.currentTarget.value);
setPage(1);
}}
/>
<Button onClick={() => setDescending(!descending)}>
Name:
{descending ? "Z–A" : "A–Z"}
</Button>
<Datagrid
aria-label="Filtered people"
columnDefs={columns}
data={pagedPeople}
getRowId={(row) => row.id}
emptyContent="No people match your filter."
/>
<Pagination
count={pageCount}
page={currentPage}
onPageChange={setPage}
aria-label="People pages"
/>
</>
}Rendering and accessibility
Each column needs a unique
idand a
header. Read a cell value with
value(row)or render custom content with
cell({ row, rowIndex, column, value }). Null and
undefined values render as empty text by default.
Give the table a
caption,
aria-label, or
aria-labelledby. The scroll container is keyboard focusable;
links and controls use normal Tab navigation. Native table elements and
explicit header associations support grouped headers. There is no
spreadsheet keyboard mode or virtualization. CSS Subgrid support is
required. Keep the combined pinned width below the viewport width so the
scrolling columns remain usable.
Native table attributes pass through to the table.
classand
stylecustomize the scroll container, including a maximum height.
emptyContentcustomizes the empty state.