# Tables, lists and display

Semantic tables, pagination, sorting affordances, avatars, image lists and timeline.

## Quick start

After the [application setup](/docs/installation/#component-consumption), copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.

```tsx
'use client';
import { useState } from 'react';
import { Table } from '@fayegram-ai/faye-ui/display/table';
import { TablePagination } from '@fayegram-ai/faye-ui/display/table-controls';

const places = ['Coast walk', 'City gardens', 'Hill trail', 'Old town'];
export function PlaceTable() {
  const [page, setPage] = useState(0);
  const [pageSize, setPageSize] = useState(2);
  const visible = pageSize === -1 ? places : places.slice(page * pageSize, (page + 1) * pageSize);
  return <>
    <Table.Container><Table.Root><Table.Caption>Places to explore</Table.Caption>
      <Table.Head><Table.Row><Table.Header>Name</Table.Header></Table.Row></Table.Head>
      <Table.Body>{visible.map(name => <Table.Row key={name}><Table.Cell>{name}</Table.Cell></Table.Row>)}</Table.Body>
    </Table.Root></Table.Container>
    <TablePagination count={places.length} page={page} pageSize={pageSize} pageSizes={[2, -1]}
      onPageChange={setPage} onPageSizeChange={size => { setPageSize(size); setPage(0); }} />
  </>;
}
```

API reference: [Table](/docs/api-display-table/), [Table Controls](/docs/api-display-table-controls/).

## Usage contract
TableSortLabel is a button; set aria-sort on the containing header cell. TablePagination accepts a known count or -1 for unknown totals. The example sorts and slices local records itself: these components do not own a data engine.

## Customization
Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.

## Source and verification
The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.

## Themed scrollbars

The scoped reset colors native document and element scrollbars with the active theme's strong-border thumb and subtle track. Standard `scrollbar-color` is preferred, with a WebKit fallback. It does not force a thin scrollbar or change standard scrollbar width as themes switch. Forced colors restores system scrollbar colors. Operating systems can hide overlay scrollbars until scrolling.

ScrollArea provides explicit track/thumb parts for bounded content, with hover/drag feedback and an inset viewport keyboard-focus ring. Applications can still style those parts or use tone variants.

## Scroll boundaries and timelines

`Table.Container` and `ScrollArea.Viewport` allow scrolling to continue through the page by default. A long panel consumes scrolling while it has room; a short panel or an exhausted panel hands the gesture to the next scrollable ancestor. Set `scrollChaining="contain"` when a panel should keep scrolling inside it. ScrollArea reserves stable room for supplied vertical and horizontal tracks, including size variants. Tracks stay mounted and hidden when they are not needed, so changing content does not resize the viewport or create a resize-observer feedback loop. Custom wheel handlers retain control of their events.

`Timeline orientation="horizontal"` places entries along a horizontal path and allows sideways scrolling when needed. The default is vertical; `position` and opposite-column placement apply to vertical layouts. The live order-tracking example lets you switch between both orientations.

## Scrollbar interaction variants

`ScrollArea.Root` accepts the shared `interaction` configuration described in [numeric controls](/docs/numeric/). The default minimal/standard preset thickens an available scrollbar on hover or dragging. A keyboard-focused viewport gives the same feedback on its scrollable axes. Classic preserves existing color feedback; soft adds a tonal underlay. Scrollbars do not acquire extra tab stops or focus borders.

Only axes with real overflow react. Inert regions and hidden, unneeded tracks stay idle. Hovering ordinary viewport content does not activate scrollbar hover feedback. The fixed track envelope reserves its largest painted size before interaction, and the viewport ends before that reserved area, so content cannot paint under either scrollbar. The envelope and gutters remain stable during hover, dragging and content changes.

Native operating-system scrollbars retain their platform behavior. Display-only progress bars and meters do not acquire these interaction effects.

In forced-color mode, custom scrollbar tracks and thumbs use contrasting system colors. The track frame follows the existing painted geometry and does not change content gutters, pointer targets or scroll behavior.

## Executable example source

```tsx
'use client';
import { useState } from 'react';
import { AvatarGroup } from '@fayegram-ai/faye-ui/display/avatar-group';
import { Avatar } from '@fayegram-ai/faye-ui/display/avatar';
import { ScrollArea } from '@fayegram-ai/faye-ui/display/scroll-area';
import { Table } from '@fayegram-ai/faye-ui/display/table';
import { TablePagination, TableSortLabel } from '@fayegram-ai/faye-ui/display/table-controls';
import { List, ListItem } from '@fayegram-ai/faye-ui/display/list';
import { ImageList, ImageListItem } from '@fayegram-ai/faye-ui/display/image-list';
import { Timeline, TimelineItem } from '@fayegram-ai/faye-ui/display/timeline';
import { Badge } from '@fayegram-ai/faye-ui/feedback/badge';
import { Grid } from '@fayegram-ai/faye-ui/layout/layout';
import { Section } from '../example';
import { Choice } from '../choice';
const records = Array.from({ length: 23 }, (_, i) => ({ id: i + 1, name: `Project ${String(i + 1).padStart(2, '0')}`, state: i % 3 ? 'Active' : 'Draft' }));
const places = [
    { title:'Coastal path',alt:'A winding path between green hills and the sea',art:'<path d="M0 125Q65 45 135 105T260 95V180H0Z" fill="#597d6b"/><path d="M0 180Q115 80 260 145V180Z" fill="#2f5347"/><path d="M115 180Q75 145 155 115" fill="none" stroke="#f4e8c9" stroke-width="10"/>' },
    { title:'City gardens',alt:'Trees beside a peaceful garden pond',art:'<path d="M0 130Q120 70 260 130V180H0Z" fill="#71916c"/><ellipse cx="180" cy="151" rx="64" ry="22" fill="#80adbb"/><path d="M55 80V145M95 60V135" stroke="#725d46" stroke-width="8"/><circle cx="55" cy="76" r="30" fill="#3f7159"/><circle cx="95" cy="55" r="33" fill="#577f58"/>' },
    { title:'Mountain trail',alt:'A trail leading toward blue mountain peaks',art:'<path d="M0 155L85 40L160 130L205 65L260 155V180H0Z" fill="#627e95"/><path d="M60 75L85 40L112 73L88 64Z" fill="#f4f0e3"/><path d="M90 180Q185 150 158 119" fill="none" stroke="#e7d4b5" stroke-width="9"/>' },
];
const artwork = (art:string) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 180"><rect width="260" height="180" fill="#dbe5e4"/><circle cx="214" cy="37" r="20" fill="#f6f0df"/>${art}</svg>`)}`;
export default function Display() {
    const [page, setPage] = useState(0), [size, setSize] = useState(10), [direction, setDirection] = useState<'ascending' | 'descending'>('ascending');
    const [listLength,setListLength]=useState('10'),[chaining,setChaining]=useState<'auto'|'contain'>('auto');
    const [imageLayout,setImageLayout]=useState<'standard'|'quilted'|'woven'>('standard'),[orientation,setOrientation]=useState<'vertical'|'horizontal'>('vertical');
    const sorted = direction === 'ascending' ? records : [...records].reverse();
    const visible = size === -1 ? sorted : sorted.slice(page * size, (page + 1) * size);
    return <>
        <Section title="Table structure and controlled pagination" note="Sort the project names or choose how many rows appear on each page.">
            <Table.Container><Table.Root><Table.Caption>Example project directory</Table.Caption><Table.Head><Table.Row><Table.Header aria-sort={direction}><TableSortLabel active direction={direction} onClick={() => setDirection(d => d === 'ascending' ? 'descending' : 'ascending')}>Project name</TableSortLabel></Table.Header><Table.Header>Status</Table.Header></Table.Row></Table.Head>
                <Table.Body>{visible.map(row => <Table.Row key={row.id}><Table.Cell>{row.name}</Table.Cell><Table.Cell><Badge>{row.state}</Badge></Table.Cell></Table.Row>)}</Table.Body><Table.Footer><Table.Row><Table.Cell colSpan={2}>{records.length} total projects</Table.Cell></Table.Row></Table.Footer></Table.Root></Table.Container>
            <TablePagination count={records.length} page={page} pageSize={size} onPageChange={setPage} onPageSizeChange={setSize} pageSizes={[5, 10, -1]}/>
        </Section>
        <Section title="Avatars, lists and bounded scrolling" note="The list scrolls first. At either end, scrolling continues through the page unless you choose to keep it inside the panel.">
            <AvatarGroup max={4} total={7} aria-label="Project collaborators">{[1, 2, 3, 4, 5].map(n => <Avatar.Root key={n}>{n === 1 && <Avatar.Image alt="Example collaborator" src="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 40 40%27%3E%3Crect width=%2740%27 height=%2740%27 fill=%27%235264aa%27/%3E%3C/svg%3E"/>}<Avatar.Fallback>{n}</Avatar.Fallback></Avatar.Root>)}</AvatarGroup>
            <Grid columns={{base:1,sm:2}} gap="3"><Choice label="List length" value={listLength} onValueChange={setListLength} options={[{value:'10',label:'Ten projects'},{value:'2',label:'Two projects'}]}/><Choice label="Scroll boundary" value={chaining} onValueChange={value=>setChaining(value as typeof chaining)} options={[{value:'auto',label:'Continue through page'},{value:'contain',label:'Keep inside panel'}]}/></Grid>
            <ScrollArea.Root style={{height:'12rem'}}><ScrollArea.Viewport role="region" aria-label="Scrollable project list" scrollChaining={chaining}><ScrollArea.Content><List>{records.slice(0,Number(listLength)).map(row => <ListItem key={row.id} leading={<Avatar.Root><Avatar.Fallback>{row.id}</Avatar.Fallback></Avatar.Root>} trailing={<Badge>{row.state}</Badge>}>{row.name}</ListItem>)}</List></ScrollArea.Content></ScrollArea.Viewport><ScrollArea.Scrollbar><ScrollArea.Thumb/></ScrollArea.Scrollbar><ScrollArea.Corner/></ScrollArea.Root>
        </Section>
        <Section title="Image gallery" note="Image tiles keep artwork and captions together. Change the layout to compare the arrangements.">
            <Choice label="Image layout" value={imageLayout} onValueChange={value=>setImageLayout(value as typeof imageLayout)} options={['standard','quilted','woven'].map(value=>({value,label:value.charAt(0).toUpperCase()+value.slice(1)}))}/>
            <ImageList variant={imageLayout} aria-label="Places to explore">{places.map((place,i)=><ImageListItem key={place.title} caption={place.title} columns={imageLayout==='quilted'&&i===0?2:1} rows={imageLayout==='quilted'&&i===0?2:1}><img src={artwork(place.art)} alt={place.alt}/></ImageListItem>)}</ImageList>
        </Section>
        <Section title="Order tracking timeline" note="Use a vertical sequence or a horizontal journey. Narrow horizontal timelines can be scrolled sideways.">
            <Choice label="Timeline orientation" value={orientation} onValueChange={value=>setOrientation(value as typeof orientation)} options={[{value:'vertical',label:'Vertical'},{value:'horizontal',label:'Horizontal'}]}/>
            <Timeline orientation={orientation} aria-label="Order tracking"><TimelineItem timestamp="09:10" dateTime="2026-10-01T09:10:00Z" tone="success">Order received</TimelineItem><TimelineItem timestamp="09:25" dateTime="2026-10-01T09:25:00Z">Preparing your order</TimelineItem><TimelineItem timestamp="10:00" dateTime="2026-10-01T10:00:00Z">Out for delivery</TimelineItem></Timeline>
        </Section>
    </>;
}
```
