TableNext

Alpha component

TableNext component is subject to major changes and is for experimentation purposes only. Not recommended for use in production software.

TableNext is the next iteration of the basic Table component. It is per default responsive by receiving a horizontal scrollbar. It additionally offers a second responsive layout mode called stackable. In the stackable layout, the Table data will be displayed in a grid, each table row represented as a single unit with all cells of this row stacked on top of each other. If a table header with titles is required, the column title is displayed next to the table cell.

Current limitations

This component is still in an unstable alpha state. The layout="stackable" variant has the following restrictions:

  • No sorting. Sorting props (isSortable, sortDirection, sortButtonAriaLabel) have no effect in stackable mode. Use layout="scrollable" for sortable tables.
  • Plain text column titles only. columnTitles accepts strings. Custom React elements — checkboxes, tooltips, icons, badges, or any other component — cannot be used as row labels in the stacked view.
  • No custom head cell content beyond text. Head cells in a stackable table should contain plain text only. Additional React elements such as sort buttons, tooltips, or icons inside TableNext.Cell within TableNext.Head are not supported in stackable mode.

Support for richer head cell content, sorting, and check-all checkboxes in stackable tables is planned for a future release.

Import

import { TableNext } from '@contentful/f36-table-next';

Examples

Basic usage

function TableBasicUsageExample() {
return (
<TableNext>
<TableNext.Head>
<TableNext.Row>
<TableNext.Cell>Name</TableNext.Cell>
<TableNext.Cell>Email</TableNext.Cell>
<TableNext.Cell>Organization role</TableNext.Cell>
<TableNext.Cell>Last activity</TableNext.Cell>
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
<TableNext.Row>
<TableNext.Cell>Claus Mitchell</TableNext.Cell>
<TableNext.Cell>claus.mitchell@contentful.com</TableNext.Cell>
<TableNext.Cell>CEO</TableNext.Cell>
<TableNext.Cell>August 29, 2018</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Johannes Ramos</TableNext.Cell>
<TableNext.Cell>johannes.ramos@contentful.com</TableNext.Cell>
<TableNext.Cell>CTO</TableNext.Cell>
<TableNext.Cell>July 27, 2019</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Alex Kalinoski</TableNext.Cell>
<TableNext.Cell>alex.kalinoski@contentful.com</TableNext.Cell>
<TableNext.Cell>CDO</TableNext.Cell>
<TableNext.Cell>June 13, 2019</TableNext.Cell>
</TableNext.Row>
</TableNext.Body>
</TableNext>
);
}

Dynamic creation

One very common use case for a table is that you will have a set of data and you would like to show a table row for each item in that set. To achieve that result, you can iterate over the data and create TableNext.Row and TableNext.Cell for each item:

function TableDynamicCreation() {
const contentTypes = [
{
id: '1',
name: 'Category',
description:
'Categories can be applied to Courses and Lessons. Assigning Multiple categories is also possible.',
updatedAt: 'Nov 15, 2021',
status: 'published',
},
{
id: '2',
updatedAt: 'Nov 15, 2021',
status: 'draft',
},
{
id: '3',
name: 'Layout',
description:
'A page consisting of freely configurable and rearrangeable content modules.',
updatedAt: 'Nov 15, 2021',
status: 'published',
},
];
return (
<TableNext>
<TableNext.Head>
<TableNext.Row>
<TableNext.Cell>Name</TableNext.Cell>
<TableNext.Cell>Description</TableNext.Cell>
<TableNext.Cell>Updated</TableNext.Cell>
<TableNext.Cell>Status</TableNext.Cell>
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
{contentTypes.map((contentType) => {
return (
<TableNext.Row key={contentType.id}>
<TableNext.Cell>{contentType.name || 'Untitled'}</TableNext.Cell>
<TableNext.Cell>{contentType.description}</TableNext.Cell>
<TableNext.Cell>{contentType.updatedAt}</TableNext.Cell>
<TableNext.Cell>
<Badge
variant={
contentType.status === 'published' ? 'positive' : 'warning'
}
>
{contentType.status}
</Badge>
</TableNext.Cell>
</TableNext.Row>
);
})}
</TableNext.Body>
</TableNext>
);
}

With sticky first column

Combine layout="scrollable" with isFirstColumnSticky when the first column contains the primary identifier for each row. This keeps the row context visible while users scroll horizontally.

function TableScrollableStickyFirstColumnExample() {
const withLongContentTableData = Array.from({ length: 5 }, (_, index) => {
const entries = [
{
name: 'How to optimize images in WordPress for faster loading',
status: 'published',
contentType: 'Blog post',
updatedBy: 'Ayman Mahmoud',
locale: 'en-US',
space: 'Marketing',
environment: 'master',
tags: 'featured, seo-optimized',
},
{
name: 'Building accessible web applications from the ground up',
status: 'changed',
contentType: 'Landing page',
updatedBy: 'Jane Roe',
locale: 'de-DE',
space: 'Documentation',
environment: 'staging',
tags: 'accessibility, review',
},
{
name: 'Content modeling best practices for large teams',
status: 'draft',
contentType: 'Case study',
updatedBy: 'John Doe',
locale: 'fr-FR',
space: 'Support',
environment: 'development',
tags: 'internal',
},
];
const entry = entries[index % entries.length];
return {
...entry,
id: `entry-${index + 1}`,
updated: `${index + 1} days ago`,
};
});
return (
<TableNext isFirstColumnSticky>
<TableNext.Head>
<TableNext.Row>
<TableNext.Cell>Name</TableNext.Cell>
<TableNext.Cell>Status</TableNext.Cell>
<TableNext.Cell>Content type</TableNext.Cell>
<TableNext.Cell>Updated by</TableNext.Cell>
<TableNext.Cell>Updated</TableNext.Cell>
<TableNext.Cell>Locale</TableNext.Cell>
<TableNext.Cell>Space</TableNext.Cell>
<TableNext.Cell>Environment</TableNext.Cell>
<TableNext.Cell>Tags</TableNext.Cell>
<TableNext.Cell>ID</TableNext.Cell>
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
{withLongContentTableData.map((item) => (
<TableNext.Row key={item.id}>
<TableNext.Cell>{item.name}</TableNext.Cell>
<TableNext.Cell>
<EntityStatusBadge
entityStatus={
item.status === 'published'
? 'published'
: item.status === 'changed'
? 'changed'
: 'draft'
}
/>
</TableNext.Cell>
<TableNext.Cell>{item.contentType}</TableNext.Cell>
<TableNext.Cell>{item.updatedBy}</TableNext.Cell>
<TableNext.Cell>{item.updated}</TableNext.Cell>
<TableNext.Cell>{item.locale}</TableNext.Cell>
<TableNext.Cell>{item.space}</TableNext.Cell>
<TableNext.Cell>{item.environment}</TableNext.Cell>
<TableNext.Cell>{item.tags}</TableNext.Cell>
<TableNext.Cell>{item.id}</TableNext.Cell>
</TableNext.Row>
))}
</TableNext.Body>
</TableNext>
);
}

With sticky header and first column

For large tables that need both horizontal and vertical scrolling, combine layout="scrollable", isFirstColumnSticky, and TableNext.Head isSticky. Place the table in a height-constrained container to avoid double scrollbars coming from the outer container.

function TableScrollableStickyHeaderAndFirstColumnExample() {
const withLongContentTableData = Array.from({ length: 5 }, (_, index) => {
const entries = [
{
name: 'How to optimize images in WordPress for faster loading',
status: 'published',
contentType: 'Blog post',
updatedBy: 'Ayman Mahmoud',
locale: 'en-US',
space: 'Marketing',
environment: 'master',
tags: 'featured, seo-optimized',
},
{
name: 'Building accessible web applications from the ground up',
status: 'changed',
contentType: 'Landing page',
updatedBy: 'Jane Roe',
locale: 'de-DE',
space: 'Documentation',
environment: 'staging',
tags: 'accessibility, review',
},
{
name: 'Content modeling best practices for large teams',
status: 'draft',
contentType: 'Case study',
updatedBy: 'John Doe',
locale: 'fr-FR',
space: 'Support',
environment: 'development',
tags: 'internal',
},
];
const entry = entries[index % entries.length];
return {
...entry,
id: `entry-${index + 1}`,
updated: `${index + 1} days ago`,
};
});
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '320px',
overflowY: 'auto',
}}
>
<TableNext isFirstColumnSticky>
<TableNext.Head isSticky>
<TableNext.Row>
<TableNext.Cell>Name</TableNext.Cell>
<TableNext.Cell>Status</TableNext.Cell>
<TableNext.Cell>Content type</TableNext.Cell>
<TableNext.Cell>Updated by</TableNext.Cell>
<TableNext.Cell>Updated</TableNext.Cell>
<TableNext.Cell>Locale</TableNext.Cell>
<TableNext.Cell>Space</TableNext.Cell>
<TableNext.Cell>Environment</TableNext.Cell>
<TableNext.Cell>Tags</TableNext.Cell>
<TableNext.Cell>ID</TableNext.Cell>
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
{withLongContentTableData.map((item) => (
<TableNext.Row key={item.id}>
<TableNext.Cell>{item.name}</TableNext.Cell>
<TableNext.Cell>
<EntityStatusBadge
entityStatus={
item.status === 'published'
? 'published'
: item.status === 'changed'
? 'changed'
: 'draft'
}
/>
</TableNext.Cell>
<TableNext.Cell>{item.contentType}</TableNext.Cell>
<TableNext.Cell>{item.updatedBy}</TableNext.Cell>
<TableNext.Cell>{item.updated}</TableNext.Cell>
<TableNext.Cell>{item.locale}</TableNext.Cell>
<TableNext.Cell>{item.space}</TableNext.Cell>
<TableNext.Cell>{item.environment}</TableNext.Cell>
<TableNext.Cell>{item.tags}</TableNext.Cell>
<TableNext.Cell>{item.id}</TableNext.Cell>
</TableNext.Row>
))}
</TableNext.Body>
</TableNext>
</div>
);
}

Sorting

Sorting is supported on non-stackable tables. Add isSortable to a head cell, pass sortDirection to reflect the active sort, and handle onClick to update state. Sorting props are not available on layout="stackable" tables.

type SortState = {
column: string;
direction: TableCellSorting;
} | null;
const contentTypes = [
{
id: '1',
name: 'Category',
description:
'Categories can be applied to Courses and Lessons. Assigning Multiple categories is also possible.',
updatedAt: 'Nov 1, 2021',
status: 'published',
},
{
id: '2',
name: 'Untitled',
updatedAt: 'Nov 11, 2021',
status: 'draft',
},
{
id: '3',
name: 'Layout',
description:
'A page consisting of freely configurable and rearrangeable content modules.',
updatedAt: 'Nov 18, 2021',
status: 'published',
},
];
function TableNextWithSortingExample() {
const [sorting, setSorting] = useState<SortState>(null);
const [sortedRows, setSortedRows] = useState(contentTypes);
const handleSort = (column: string) => {
const direction =
sorting?.column === column
? sorting.direction === TableCellSorting.Ascending
? TableCellSorting.Descending
: TableCellSorting.Ascending
: TableCellSorting.Ascending;
setSortedRows((rows) => {
const sorted = [...rows].sort((a, b) =>
(a[column as keyof typeof a] ?? '').localeCompare(
b[column as keyof typeof b] ?? '',
),
);
return direction === TableCellSorting.Ascending
? sorted
: sorted.reverse();
});
setSorting({ column, direction });
};
return (
<TableNext>
<TableNext.Head>
<TableNext.Row>
<TableNext.Cell
isSortable
onClick={() => handleSort('name')}
sortDirection={
sorting?.column === 'name' ? sorting.direction : undefined
}
>
Name
</TableNext.Cell>
<TableNext.Cell>Description</TableNext.Cell>
<TableNext.Cell
isSortable
onClick={() => handleSort('updatedAt')}
sortDirection={
sorting?.column === 'updatedAt' ? sorting.direction : undefined
}
>
Updated
</TableNext.Cell>
<TableNext.Cell>Status</TableNext.Cell>
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
{sortedRows.map((item) => (
<TableNext.Row key={item.id}>
<TableNext.Cell>{item.name}</TableNext.Cell>
<TableNext.Cell>{item.description}</TableNext.Cell>
<TableNext.Cell>{item.updatedAt}</TableNext.Cell>
<TableNext.Cell>
<Badge
variant={item.status === 'published' ? 'positive' : 'warning'}
>
{item.status}
</Badge>
</TableNext.Cell>
</TableNext.Row>
))}
</TableNext.Body>
</TableNext>
);
}

When to use the stackable layout

Use layout="stackable" when the table needs to remain readable in narrow containers — for example in sidebars, panels, or split-pane layouts — or when the page may be viewed on small viewports where horizontal scrolling would be disruptive. The stackable layout reflows each row into a vertically stacked block, keeping all values visible without requiring the user to scroll sideways.

Prefer layout="scrollable" when the table has many columns that need to stay in view at once, when column relationships matter for scanning across rows, or when sorting is required.

Stacked responsive behaviour with column titles

For containers smaller than the stackableBreakpoint (default 700px) the table will be presented as 90° flipped, stacking all table cells of a single table row underneath each other with column titles next to them. Pass stackableBreakpoint to override the breakpoint — numbers are treated as px, strings accept px, rem, or em.

function StackableWithTitleExample() {
const stackableColumnTitles = [
'Name',
'Email',
'Organization role',
'Last activity',
];
return (
<TableNext layout="stackable" columnTitles={stackableColumnTitles}>
<TableNext.Head>
<TableNext.Row>
{stackableColumnTitles.map((title, id) => (
<TableNext.Cell key={`table-head-cell-${title}-${id}`}>
{title}
</TableNext.Cell>
))}
</TableNext.Row>
</TableNext.Head>
<TableNext.Body>
<TableNext.Row>
<TableNext.Cell>Claus Mitchell</TableNext.Cell>
<TableNext.Cell>claus.mitchell@contentful.com</TableNext.Cell>
<TableNext.Cell>CEO</TableNext.Cell>
<TableNext.Cell>August 29, 2018</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Johannes Ramos</TableNext.Cell>
<TableNext.Cell>johannes.ramos@contentful.com</TableNext.Cell>
<TableNext.Cell>CTO</TableNext.Cell>
<TableNext.Cell>July 27, 2019</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Alex Kalinoski</TableNext.Cell>
<TableNext.Cell>alex.kalinoski@contentful.com</TableNext.Cell>
<TableNext.Cell>CDO</TableNext.Cell>
<TableNext.Cell>June 13, 2019</TableNext.Cell>
</TableNext.Row>
</TableNext.Body>
</TableNext>
);
}

Stacked responsive behaviour without titles

For containers smaller than the stackableBreakpoint (default 700px) the table will be presented as 90° flipped, stacking all table cells of a single row underneath each other. Pass stackableBreakpoint to override the breakpoint — numbers are treated as px, strings accept px, rem, or em.

function StackableExample() {
return (
<TableNext layout="stackable">
<TableNext.Body>
<TableNext.Row>
<TableNext.Cell>Claus Mitchell</TableNext.Cell>
<TableNext.Cell>claus.mitchell@contentful.com</TableNext.Cell>
<TableNext.Cell>CEO</TableNext.Cell>
<TableNext.Cell>August 29, 2018</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Johannes Ramos</TableNext.Cell>
<TableNext.Cell>johannes.ramos@contentful.com</TableNext.Cell>
<TableNext.Cell>CTO</TableNext.Cell>
<TableNext.Cell>July 27, 2019</TableNext.Cell>
</TableNext.Row>
<TableNext.Row>
<TableNext.Cell>Alex Kalinoski</TableNext.Cell>
<TableNext.Cell>alex.kalinoski@contentful.com</TableNext.Cell>
<TableNext.Cell>CDO</TableNext.Cell>
<TableNext.Cell>June 13, 2019</TableNext.Cell>
</TableNext.Row>
</TableNext.Body>
</TableNext>
);
}

Props (API reference)

Open in Storybook

TableNext

Name

Type

Default

className
string

CSS class to be appended to the root element

columnTitles
string[]

Column titles used as inline labels in stacked rows at small container sizes. Only valid with layout="stackable". The order must match the column order of your TableHead row. Only supported with layout="stackable".

isFirstColumnSticky
false
true

false
layout
"stackable"
"scrollable"

scrollable
stackableBreakpoint
number
`${number}px`
`${number}rem`
`${number}em`

Container width at which the table switches to the stacked layout. Numbers are interpreted as px. Only available when layout is "stackable". Only supported with layout="stackable".

700
testId
string

A [data-test-id] attribute used for testing purposes

cf-ui-table-next
variant
"inline"
"embedded"

inline
verticalAlign
"baseline"
"bottom"
"middle"
"top"

top

TableNext.Body

Name

Type

Default

children
required
ReactNode

className
string

CSS class to be appended to the root element

testId
string

A [data-test-id] attribute used for testing purposes

TableNext.Row

Name

Type

Default

children
required
ReactNode

className
string

CSS class to be appended to the root element

isSelected
false
true

false
testId
string

A [data-test-id] attribute used for testing purposes

cf-ui-table-row

TableNext.Cell

Name

Type

Default

align
"center"
"left"
"right"

as
"a"
"abbr"
"address"
"animate"
"animateMotion"
"animateTransform"
"area"
"article"
"aside"
"audio"
"b"
"base"
"bdi"
"bdo"
"big"
"blockquote"
"body"
"br"
"button"
"canvas"
"caption"
"center"
"circle"
"cite"
"clipPath"
"code"
"col"
"colgroup"
"data"
"datalist"
"dd"
"defs"
"del"
"desc"
"details"
"dfn"
"dialog"
"div"
"dl"
"dt"
"ellipse"
"em"
"embed"
"feBlend"
"feColorMatrix"
"feComponentTransfer"
"feComposite"
"feConvolveMatrix"
"feDiffuseLighting"
"feDisplacementMap"
"feDistantLight"
"feDropShadow"
"feFlood"
"feFuncA"
"feFuncB"
"feFuncG"
"feFuncR"
"feGaussianBlur"
"feImage"
"feMerge"
"feMergeNode"
"feMorphology"
"feOffset"
"fePointLight"
"feSpecularLighting"
"feSpotLight"
"feTile"
"feTurbulence"
"fieldset"
"figcaption"
"figure"
"filter"
"footer"
"foreignObject"
"form"
"g"
"h1"
"h2"
"h3"
"h4"
"h5"
"h6"
"head"
"header"
"hgroup"
"hr"
"html"
"i"
"iframe"
"image"
"img"
"input"
"ins"
"kbd"
"keygen"
"label"
"legend"
"li"
"line"
"linearGradient"
"link"
"main"
"map"
"mark"
"marker"
"mask"
"menu"
"menuitem"
"meta"
"metadata"
"meter"
"mpath"
"nav"
"noindex"
"noscript"
"object"
"ol"
"optgroup"
"option"
"output"
"p"
"param"
"path"
"pattern"
"picture"
"polygon"
"polyline"
"pre"
"progress"
"q"
"radialGradient"
"rect"
"rp"
"rt"
"ruby"
"s"
"samp"
"script"
"search"
"section"
"select"
"set"
"slot"
"small"
"source"
"span"
"stop"
"strong"
"style"
"sub"
"summary"
"sup"
"svg"
"switch"
"symbol"
"table"
"tbody"
"td"
"template"
"text"
"textarea"
"textPath"
"tfoot"
"th"
"thead"
"time"
"title"
"tr"
"track"
"tspan"
"u"
"ul"
"use"
"var"
"video"
"view"
"wbr"
"webview"
ComponentClass<any, any>
FunctionComponent<any>

children
ReactNode

className
string

CSS class to be appended to the root element

isSortable
false
true

isTruncated
false
true

isWordBreak
false
true

sortButtonAriaLabel
string

Override auto-generated aria-label on the sort button

sortDirection
"ascending"
"descending"

testId
string

A [data-test-id] attribute used for testing purposes

width
string
number

Content guidelines

  • Table Column Titles should be short and descriptive

Accessibility

  • It will render tabular data using the native HTML element table which is recommended.