<!-- Source: https://table.svelte.page/examples/kitchen-sink -->

# Kitchen Sink

> Every Svelte Headless Table plugin composed onto one table — sorting, filtering, pagination, grouping, expansion, selection, column resizing, sub-rows, hidden columns, and column reordering.

**Source:** [https://table.svelte.page/examples/kitchen-sink](https://table.svelte.page/examples/kitchen-sink)

**Markdown mirror:** [https://table.svelte.page/examples/kitchen-sink.md](https://table.svelte.page/examples/kitchen-sink.md)

---

This mirror preserves the prose, implementation notes, and runnable Svelte source behind the live example page.

## FIG-001: every plugin.

A single table wired through `createTable` with the full plugin stack composed at once — text + column filters, sortable headers, pagination, sub-rows, group-by, expansion, selection, column resizing, hidden columns, column ordering, and data export.

**Metadata:** tag: `COMPOSE` | pattern: `plugin composition`

### Notes

- Plugins compose declaratively on the second argument to `createTable` — each one returns a fresh instance, so order doesn't matter for setup, only for which derived state wraps which.
- Header + body rows are produced by separate methods on the view model so you can inject merge, group, and resize behaviour without rewriting the render loop.
- Filter renderers (`TextFilter`, `NumberRangeFilter`, `SelectFilter`) are ordinary Svelte components passed through `createRender` — bring your own UI library.

### Source

#### Default.svelte

Source file: [src/lib/examples/kitchen-sink/demos/Default.svelte](https://github.com/humanspeak/svelte-headless-table/blob/main/docs/src/lib/examples/kitchen-sink/demos/Default.svelte)

```svelte
<script lang="ts">
    import { derived, readable } from 'svelte/store'
    import { Render, Subscribe, createTable, createRender } from '@humanspeak/svelte-headless-table'
    import {
        addColumnFilters,
        addColumnOrder,
        addHiddenColumns,
        addSortBy,
        addTableFilter,
        addPagination,
        addExpandedRows,
        matchFilter,
        numberRangeFilter,
        textPrefixFilter,
        addSubRows,
        addGroupBy,
        addSelectedRows,
        addResizedColumns
    } from '@humanspeak/svelte-headless-table/plugins'
    import { ChevronDown, ChevronUp, Layers, Shuffle } from '@lucide/svelte'

    import { mean, sum } from '$lib/utils/math'
    import { getShuffled } from '$lib/utils/getShuffled'
    import { createSamples } from '$lib/utils/createSamples'
    import Italic from '$lib/examples/_shared/Italic.svelte'
    import Profile from '$lib/examples/_shared/Profile.svelte'
    import TextFilter from '$lib/examples/_shared/TextFilter.svelte'
    import NumberRangeFilter from '$lib/examples/_shared/NumberRangeFilter.svelte'
    import SelectFilter from '$lib/examples/_shared/SelectFilter.svelte'
    import ExpandIndicator from '$lib/examples/_shared/ExpandIndicator.svelte'
    import { getDistinct } from '$lib/utils/array'
    import SelectIndicator from '$lib/examples/_shared/SelectIndicator.svelte'

    // `createSamples(length, depth, breadth)` creates `length` items at
    // every level (not just the top), and `breadth` *arrays* of children
    // per parent — so each parent ends up with `breadth × length`
    // children. (8, 2, 1) → 8 parents × 8 children = 72 rows total,
    // enough to demo expand/collapse without making pagination redraw
    // a 100k-row dataset on every refresh.
    const data = readable(createSamples(8, 2, 1, { seed: 42 }))

    const table = createTable(data, {
        subRows: addSubRows({
            children: 'children'
        }),
        filter: addColumnFilters(),
        tableFilter: addTableFilter({
            includeHiddenColumns: true
        }),
        group: addGroupBy({
            initialGroupByIds: []
        }),
        sort: addSortBy(),
        expand: addExpandedRows({
            initialExpandedIds: { 1: true }
        }),
        select: addSelectedRows({
            initialSelectedDataIds: { 1: true }
        }),
        orderColumns: addColumnOrder(),
        hideColumns: addHiddenColumns(),
        page: addPagination({
            initialPageSize: 20
        }),
        resize: addResizedColumns()
    })

    const columns = table.createColumns([
        table.display({
            id: 'selected',
            header: '',
            cell: ({ row }, { pluginStates }) => {
                const { isSelected, isSomeSubRowsSelected } = pluginStates.select.getRowState(row)
                return createRender(SelectIndicator, {
                    isSelected,
                    isSomeSubRowsSelected
                })
            },
            plugins: {
                resize: {
                    disable: true
                }
            }
        }),
        table.display({
            id: 'expanded',
            header: '',
            cell: ({ row }, { pluginStates }) => {
                const { isExpanded, canExpand, isAllSubRowsExpanded } =
                    pluginStates.expand.getRowState(row)
                return createRender(ExpandIndicator, {
                    isExpanded,
                    canExpand,
                    isAllSubRowsExpanded,
                    depth: row.depth
                })
            },
            plugins: {
                resize: {
                    disable: true
                }
            }
        }),
        table.column({
            header: 'Summary',
            id: 'summary',
            accessor: (item) => item,
            cell: ({ value }) =>
                createRender(Profile, {
                    age: value.age,
                    progress: value.progress,
                    name: `${value.firstName} ${value.lastName}`
                }),
            plugins: {
                sort: {
                    getSortValue: (i) => i.lastName
                },
                tableFilter: {
                    getFilterValue: (i) => i.progress
                }
            }
        }),
        table.group({
            header: (_, { rows, pageRows }) =>
                derived(
                    [rows, pageRows],
                    ([_rows, _pageRows]) =>
                        `Name (${_rows.length} records, ${_pageRows.length} in page)`
                ),
            columns: [
                table.column({
                    header: createRender(Italic, { text: 'First Name' }),
                    accessor: 'firstName',
                    plugins: {
                        group: {
                            getAggregateValue: (values) => getDistinct(values).length,
                            cell: ({ value }) => `${value} unique`
                        },
                        sort: {
                            invert: true
                        },
                        filter: {
                            fn: textPrefixFilter,
                            render: ({ filterValue, values }) =>
                                createRender(TextFilter, { filterValue, values })
                        }
                    }
                }),
                table.column({
                    header: () => 'Last Name',
                    accessor: 'lastName',
                    plugins: {
                        group: {
                            getAggregateValue: (values) => getDistinct(values).length,
                            cell: ({ value }) => `${value} unique`
                        }
                    }
                })
            ]
        }),
        table.group({
            header: (_, { rows }) =>
                createRender(
                    Italic,
                    derived(rows, (_rows) => ({ text: `Info (${_rows.length} samples)` }))
                ),
            columns: [
                table.column({
                    header: 'Age',
                    accessor: 'age',
                    plugins: {
                        group: {
                            getAggregateValue: (values) => mean(values),
                            cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`
                        },
                        resize: {
                            minWidth: 50,
                            initialWidth: 100,
                            maxWidth: 200
                        }
                    }
                }),
                table.column({
                    header: 'Status',
                    id: 'status',
                    accessor: (item) => item.status,
                    plugins: {
                        sort: {
                            disable: true
                        },
                        filter: {
                            fn: matchFilter,
                            render: ({ filterValue, preFilteredValues }) =>
                                createRender(SelectFilter, { filterValue, preFilteredValues })
                        },
                        tableFilter: {
                            exclude: true
                        },
                        resize: {
                            disable: true
                        }
                    }
                }),
                table.column({
                    header: 'Visits',
                    accessor: 'visits',
                    plugins: {
                        group: {
                            getAggregateValue: (values) => sum(values),
                            cell: ({ value }) => `${value} (total)`
                        },
                        filter: {
                            fn: numberRangeFilter,
                            initialFilterValue: [null, null],
                            render: ({ filterValue, values }) =>
                                createRender(NumberRangeFilter, { filterValue, values })
                        }
                    }
                }),
                table.column({
                    header: 'Profile Progress',
                    accessor: 'progress',
                    plugins: {
                        group: {
                            getAggregateValue: (values) => mean(values),
                            cell: ({ value }) => `${(value as number).toFixed(2)} (avg)`
                        }
                    }
                })
            ]
        })
    ])

    const {
        flatColumns,
        headerRows,
        pageRows,
        tableAttrs,
        tableBodyAttrs,
        visibleColumns,
        pluginStates
    } = table.createViewModel(columns)
    const ids = flatColumns.map((c) => c.id)

    const { groupByIds } = pluginStates.group
    const { sortKeys } = pluginStates.sort
    const { filterValues } = pluginStates.filter
    const { filterValue } = pluginStates.tableFilter
    const { selectedDataIds } = pluginStates.select
    const { pageIndex, pageCount, pageSize, hasPreviousPage, hasNextPage } = pluginStates.page
    const { expandedIds } = pluginStates.expand
    const { columnIdOrder } = pluginStates.orderColumns
    const { hiddenColumnIds } = pluginStates.hideColumns
    const { columnWidths } = pluginStates.resize

    // `const` + `$state` — mutation goes through the rune's Proxy, the
    // binding itself never reassigns. `let` would trigger
    // `eslint/prefer-const`; ESLint's prefer-const doesn't know about runes.
    const hideForId: Record<string, boolean> = $state(
        Object.fromEntries(ids.map((id) => [id, false]))
    )

    $effect(() => {
        $hiddenColumnIds = Object.entries(hideForId)
            .filter(([, hide]) => hide)
            .map(([id]) => id)
    })
</script>

<div class="ks-toolbar">
    <fieldset class="ks-panel">
        <legend>Hidden columns</legend>
        <div class="ks-checks">
            {#each ids as id (id)}
                <label class="ks-check" for="hide-{id}">
                    <input id="hide-{id}" type="checkbox" bind:checked={hideForId[id]} />
                    <span>{id}</span>
                </label>
            {/each}
        </div>
    </fieldset>

    <fieldset class="ks-panel">
        <legend>Pagination</legend>
        <div class="ks-pager">
            <button
                type="button"
                class="ks-btn"
                onclick={() => $pageIndex--}
                disabled={!$hasPreviousPage}>‹ prev</button
            >
            <span class="ks-pager-meta">
                <strong>{$pageIndex + 1}</strong> / {$pageCount}
            </span>
            <button
                type="button"
                class="ks-btn"
                onclick={() => $pageIndex++}
                disabled={!$hasNextPage}>next ›</button
            >
            <label class="ks-inline" for="page-size">
                <span>rows</span>
                <input id="page-size" type="number" min={1} bind:value={$pageSize} />
            </label>
        </div>
    </fieldset>

    <fieldset class="ks-panel">
        <legend>Column order</legend>
        <button
            type="button"
            class="ks-btn ks-btn--icon"
            onclick={() => {
                // The plugin's columnIdOrder starts empty and falls back to
                // declaration order. Seed it with the live ID list on first
                // shuffle so a single click actually rearranges columns.
                const current = $columnIdOrder.length ? $columnIdOrder : ids
                $columnIdOrder = getShuffled(current)
            }}
        >
            <Shuffle size={14} strokeWidth={2.25} />
            shuffle
        </button>
    </fieldset>
</div>

<div class="ks-shell">
    <table {...$tableAttrs} class="ks-table">
        <thead>
            {#each $headerRows as headerRow (headerRow.id)}
                <Subscribe attrs={headerRow.attrs()} let:attrs>
                    <tr {...attrs}>
                        {#each headerRow.cells as cell (cell.id)}
                            <Subscribe
                                attrs={cell.attrs()}
                                let:attrs
                                props={cell.props()}
                                let:props
                            >
                                <th
                                    {...attrs}
                                    onclick={props.sort.toggle}
                                    class:sorted={props.sort.order !== undefined}
                                    use:props.resize
                                >
                                    <div class="ks-th-inner">
                                        <span class="ks-th-label">
                                            <Render of={cell.render()} />
                                        </span>
                                        {#if props.sort.order === 'asc'}
                                            <ChevronDown size={12} strokeWidth={2.5} />
                                        {:else if props.sort.order === 'desc'}
                                            <ChevronUp size={12} strokeWidth={2.5} />
                                        {/if}
                                        {#if !props.group.disabled}
                                            <button
                                                type="button"
                                                class="ks-th-action"
                                                title={props.group.grouped
                                                    ? 'Ungroup'
                                                    : 'Group by this column'}
                                                onclick={(e) => {
                                                    e.stopPropagation()
                                                    props.group.toggle(e)
                                                }}
                                            >
                                                <Layers size={12} strokeWidth={2.25} />
                                                {props.group.grouped ? 'ungroup' : 'group'}
                                            </button>
                                        {/if}
                                    </div>
                                    {#if props.filter?.render !== undefined}
                                        <div
                                            class="ks-th-filter"
                                            onclick={(e) => e.stopPropagation()}
                                        >
                                            <Render of={props.filter.render} />
                                        </div>
                                    {/if}
                                    {#if !props.resize.disabled}
                                        <div
                                            class="ks-resizer"
                                            onclick={(e) => e.stopPropagation()}
                                            use:props.resize.drag
                                        ></div>
                                    {/if}
                                </th>
                            </Subscribe>
                        {/each}
                    </tr>
                </Subscribe>
            {/each}
            <tr class="ks-search-row">
                <th colspan={$visibleColumns.length}>
                    <input
                        type="text"
                        bind:value={$filterValue}
                        placeholder="Search all rows…"
                        class="ks-search"
                    />
                </th>
            </tr>
        </thead>
        <tbody {...$tableBodyAttrs}>
            {#each $pageRows as row (row.id)}
                <Subscribe attrs={row.attrs()} let:attrs rowProps={row.props()} let:rowProps>
                    <tr {...attrs} class:selected={rowProps.select.selected}>
                        {#each row.cells as cell (cell.id)}
                            <Subscribe
                                attrs={cell.attrs()}
                                let:attrs
                                props={cell.props()}
                                let:props
                            >
                                <td
                                    {...attrs}
                                    class:sorted={props.sort.order !== undefined}
                                    class:matches={props.tableFilter.matches}
                                    class:group={props.group.grouped}
                                    class:aggregate={props.group.aggregated}
                                    class:repeat={props.group.repeated}
                                >
                                    {#if !props.group.repeated}
                                        <Render of={cell.render()} />
                                    {/if}
                                </td>
                            </Subscribe>
                        {/each}
                    </tr>
                </Subscribe>
            {/each}
        </tbody>
    </table>
</div>

<details class="ks-state">
    <summary>plugin state · debug</summary>
    <pre>{JSON.stringify(
            {
                groupByIds: $groupByIds,
                sortKeys: $sortKeys,
                filterValues: $filterValues,
                selectedDataIds: $selectedDataIds,
                columnIdOrder: $columnIdOrder,
                hiddenColumnIds: $hiddenColumnIds,
                expandedIds: $expandedIds,
                columnWidths: $columnWidths
            },
            null,
            2
        )}</pre>
</details>

<style>
    /* ── Toolbar ────────────────────────────────────────────────────── */
    .ks-toolbar {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
        gap: 12px;
        margin-bottom: 14px;
    }

    .ks-panel {
        border: 1px solid var(--border);
        background: var(--background);
        margin: 0;
        padding: 12px 14px 14px;
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 0.8em;
    }
    .ks-panel legend {
        padding: 0 6px;
        font-family: var(--prose-sans), system-ui, sans-serif;
        font-size: 0.7em;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--foreground) 70%, transparent);
    }

    .ks-checks {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
        gap: 6px 12px;
    }
    .ks-check {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        cursor: pointer;
        font-size: 0.95em;
    }
    .ks-check input {
        accent-color: var(--color-brand-500, var(--foreground));
    }

    .ks-pager {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 8px;
    }
    .ks-pager-meta {
        font-variant-numeric: tabular-nums;
        color: color-mix(in srgb, var(--foreground) 75%, transparent);
    }
    .ks-pager-meta strong {
        color: var(--foreground);
    }
    .ks-inline {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        margin-left: auto;
    }
    .ks-inline span {
        font-size: 0.75em;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--foreground) 65%, transparent);
    }
    .ks-inline input {
        width: 64px;
        padding: 3px 6px;
        font-family: inherit;
        font-size: 0.95em;
        color: var(--foreground);
        background: var(--background);
        border: 1px solid var(--border);
        border-radius: 0;
        outline: none;
    }
    .ks-inline input:focus {
        border-color: var(--color-brand-500, var(--foreground));
    }

    .ks-btn {
        all: unset;
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 4px 10px;
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 0.9em;
        color: var(--foreground);
        border: 1px solid var(--border);
        background: var(--background);
        cursor: pointer;
        transition:
            background 100ms ease,
            color 100ms ease,
            border-color 100ms ease;
    }
    .ks-btn:hover:not(:disabled) {
        background: var(--foreground);
        color: var(--background);
        border-color: var(--foreground);
    }
    .ks-btn:disabled {
        opacity: 0.35;
        cursor: not-allowed;
    }
    .ks-btn--icon :global(svg) {
        margin-right: 2px;
    }

    /* ── Table shell ────────────────────────────────────────────────── */
    .ks-shell {
        border: 1px solid var(--border);
        background: var(--background);
        overflow: auto;
        max-height: 620px;
    }

    .ks-table {
        width: 100%;
        border-collapse: collapse;
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 0.85em;
        color: var(--foreground);
    }

    .ks-table thead {
        position: sticky;
        top: 0;
        z-index: 2;
        background: color-mix(in srgb, var(--muted, var(--foreground)) 6%, var(--background));
    }
    .ks-table thead tr:first-child th {
        background: color-mix(in srgb, var(--muted, var(--foreground)) 12%, var(--background));
    }

    .ks-table th,
    .ks-table td {
        border-bottom: 1px solid var(--border);
        border-right: 1px solid var(--border);
        padding: 6px 10px;
        text-align: left;
        vertical-align: middle;
        white-space: nowrap;
    }
    .ks-table th:last-child,
    .ks-table td:last-child {
        border-right: 0;
    }
    .ks-table thead th {
        font-family: var(--prose-sans), system-ui, sans-serif;
        font-weight: 600;
        font-size: 0.7em;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--foreground) 70%, transparent);
        cursor: pointer;
        position: relative;
    }
    .ks-th-inner {
        display: inline-flex;
        align-items: center;
        gap: 6px;
    }
    .ks-th-label {
        display: inline-flex;
        align-items: center;
        gap: 4px;
    }
    .ks-th-action {
        all: unset;
        display: inline-flex;
        align-items: center;
        gap: 3px;
        padding: 1px 6px;
        margin-left: 4px;
        font-size: 0.85em;
        letter-spacing: 0.04em;
        cursor: pointer;
        border: 1px solid var(--border);
        background: var(--background);
        color: color-mix(in srgb, var(--foreground) 75%, transparent);
    }
    .ks-th-action:hover {
        background: var(--foreground);
        color: var(--background);
        border-color: var(--foreground);
    }
    .ks-th-filter {
        margin-top: 6px;
        text-transform: none;
        letter-spacing: 0;
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 1em;
        font-weight: 400;
    }
    .ks-resizer {
        position: absolute;
        top: 0;
        bottom: 0;
        right: -4px;
        width: 8px;
        z-index: 1;
        cursor: col-resize;
        background: transparent;
        transition: background 100ms ease;
    }
    .ks-resizer:hover {
        background: color-mix(in srgb, var(--color-brand-500, currentColor) 35%, transparent);
    }

    .ks-search-row th {
        background: var(--background);
        cursor: default;
        padding: 4px 8px;
    }
    .ks-search {
        width: 100%;
        padding: 4px 8px;
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 0.95em;
        color: var(--foreground);
        background: var(--background);
        border: 1px solid var(--border);
        border-radius: 0;
        outline: none;
    }
    .ks-search:focus {
        border-color: var(--color-brand-500, var(--foreground));
    }

    .ks-table tbody tr {
        transition: background 80ms ease;
    }
    .ks-table tbody tr:hover {
        background: color-mix(in srgb, var(--color-brand-500, var(--foreground)) 5%, transparent);
    }
    .ks-table tbody tr:nth-child(even) {
        background: color-mix(in srgb, var(--muted, var(--foreground)) 3%, transparent);
    }

    /* ── State badges on cells ──────────────────────────────────────── */
    .ks-table tbody td.sorted {
        background: color-mix(in srgb, var(--color-brand-500, currentColor) 8%, transparent);
    }
    .ks-table tbody td.matches {
        font-weight: 600;
        color: var(--foreground);
    }
    .ks-table tbody td.group {
        background: color-mix(in srgb, var(--color-brand-500, currentColor) 14%, transparent);
        font-weight: 600;
    }
    .ks-table tbody td.aggregate {
        background: color-mix(in srgb, gold 18%, transparent);
        font-style: italic;
    }
    .ks-table tbody td.repeat {
        background: color-mix(in srgb, currentColor 4%, transparent);
    }
    .ks-table tbody tr.selected {
        background: color-mix(in srgb, var(--color-brand-500, currentColor) 12%, transparent);
    }
    .ks-table tbody tr.selected:hover {
        background: color-mix(in srgb, var(--color-brand-500, currentColor) 18%, transparent);
    }

    /* ── Debug panel ────────────────────────────────────────────────── */
    .ks-state {
        margin-top: 14px;
        border: 1px solid var(--border);
        background: var(--background);
    }
    .ks-state summary {
        cursor: pointer;
        padding: 8px 12px;
        font-family: var(--prose-sans), system-ui, sans-serif;
        font-size: 0.7em;
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--foreground) 70%, transparent);
        list-style: none;
    }
    .ks-state summary::before {
        content: '▸ ';
        display: inline-block;
        transform-origin: center;
        transition: transform 120ms ease;
    }
    .ks-state[open] summary::before {
        transform: rotate(90deg);
    }
    .ks-state pre {
        margin: 0;
        padding: 12px 14px;
        border-top: 1px solid var(--border);
        background: color-mix(in srgb, var(--muted, var(--foreground)) 4%, transparent);
        font-family: var(--prose-mono, ui-monospace, monospace);
        font-size: 0.78em;
        line-height: 1.45;
        color: color-mix(in srgb, var(--foreground) 85%, transparent);
        overflow: auto;
        max-height: 260px;
    }
</style>
```
