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.

People directory
IDPersonPersonWorkWork
NameContactTeamSalary
EmailCity
1Alex Morganalex@example.comLisbonDesign€82,000
2Sam Riverasam@example.comLondonEngineering€96,000
3Jamie Chenjamie@example.comBerlinDesign€88,000
4Taylor Reedtaylor@example.comMadridOperations€76,000
GroupedDatagridExample.tsrx
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.

NameTeamCity
Alex MorganDesignLisbon
Avery ShahEngineeringAmsterdam
Casey BloomDesignParis
ControlledDatagridExample.tsrx
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.