Data table
The data table organizes large sets of data into rows and columns, layering sorting, filtering, pagination, selection, grouping and expandable rows on top of a plain Table. It is built for server-driven data, so you feed it one page of rows at a time.
Required icons
Props
items: T[]
The data to show in the table. This should already be the current page's data. Pagination is handled server-side. Pass the subset of items for the active page here.
is-filled?: boolean
Renders a filler row that stretches to the bottom of the table when the page has fewer items than `per-page`.
group-by?: (item: T) => string | number
An accessor that returns the group id for each item. When set, rows are grouped and the `group` slot is rendered as the header for each group.
collapse-mode?: 'unmount' | 'hide'
How a collapsed group's rows are handled. Use `unmount` to drop them from the DOM, or `hide` to keep them mounted and hidden so toggling groups with many rich rows stays instant. Only applies when `group-by` is set.
Default: unmount
is-hoverable?: boolean
Enable a hover state for each row.
is-loading?: boolean
Show that the data table is loading.
is-sticky?: boolean
If the table header, including the filter bar, sticks to the top while scrolling.
limits: number[]
The available options for the pagination limit.
page: number
The currently active page, starting from 1. Used for slot bindings and the pagination bar display only; it does not slice the items array.
per-page: number
The number of rows to show per page.
expand-mode?: 'single' | 'multiple'
How expandable rows behave. Use `multiple` to allow several rows open at once, or `single` to keep only one open. Requires `unique-key` and the `expandable` slot.
Default: multiple
can-expand?: (item: T) => boolean
A predicate that decides whether a row shows its expand toggle. Return `false` to hide the toggle for rows without detail content. Requires the `expandable` slot.
row-color?: (item: T) => FluxColor | undefined
An accessor that returns the color for each row, for example `danger` for a row that failed. Return `undefined` to leave a row untinted. A selected row keeps its selected styling.
selection-mode?: 'single' | 'multiple'
Enables row selection. Use `single` for at most one selected row, or `multiple` for an array of selected rows. Requires `unique-key` to be set.
total: number
The total number of items in the data set.
unique-key?: string
The unique key for each row.
Emits
limit: [number]
Triggered when the per-page limit is changed by the user.
navigate: [number]
Triggered when the user navigates to a different page.
row-click: [T, number, MouseEvent]
Triggered when a row is activated by a click, or by pressing `Enter`/`Space` while the row is focused, with the row's item, the index of the clicked column and the original event. Activations from interactive elements within the row (buttons, links, inputs) are ignored. Not triggered when `selection-mode` is set, since there the row activation toggles the selection instead. The column index is the raw cell index (the selection and expand cells are counted), or `-1` when the row is activated by keyboard.
update:selected: [string | number | null | (string | number)[]]
Triggered when the selection changes. The payload type matches `selection-mode`, so it is a single id (or `null`) for `single` and an array of ids for `multiple`.
update:expanded: [(string | number)[]]
Triggered when the set of expanded rows changes, with the array of expanded ids.
update:collapsed-groups: [(string | number)[]]
Triggered when a group is collapsed or expanded, with the array of collapsed group ids.
Slots
empty
Renders in place of the rows when there is no data and the table is not loading. Defaults to a centered message.
loading ({
readonly page: number;
readonly per-page: number;
readonly total: number;
})
Replaces the default spinner overlay while `is-loading` is set. Renders inside the table body on the table's column grid, typically with skeleton rows. Use `per-page` to render the expected number of rows. The regular rows are not rendered while this slot shows.
selection ({
selected(string | number)[];
readonly count: number;
clear() => void;
})
Renders as a full-width bar in place of the `filter` row while at least one row is selected, typically with the selection count and bulk actions. Requires `selection-mode`.
filter ({
readonly page: number;
readonly per-page: number;
readonly items: T[];
readonly total: number;
})
Renders above the table header, typically a `FluxFilterBar` for filtering the data set.
footer ({
readonly page: number;
readonly per-page: number;
readonly items: T[];
readonly total: number;
})
The footer of the data table.
header ({
readonly page: number;
readonly per-page: number;
readonly items: T[];
readonly total: number;
})
The header of the data table.
pagination ({
readonly page: number;
readonly per-page: number;
readonly items: T[];
readonly total: number;
})
Replaces the default pagination bar, useful when you want to render a custom paginator.
expandable ({
readonly index: number;
readonly item: T;
readonly is-expanded: boolean;
toggle() => void;
})
When provided, each row gets an expand toggle and this slot renders the expanded detail content spanning the full table width. Requires `unique-key`.
group ({
readonly id: string | number;
readonly index: number;
readonly items: T[];
readonly is-expanded: boolean;
toggle() => void;
})
Renders the header row for each group. Only used when `group-by` is set. Render a `FluxTableGroup` here, wiring `is-expanded` and `toggle` to make the group collapsible.
[key: string] ({
readonly index: number;
readonly page: number;
readonly per-page: number;
readonly item: T;
readonly items: T[];
readonly total: number;
readonly is-selected: boolean;
})
A slot representing a cell for each visible row.
Server-side pagination
The data table does not paginate items internally. The items prop should contain only the rows for the currently active page, fetched from your server or API. The page and per-page props are used to drive the pagination bar and are exposed through slot bindings so you can display them, but the component never slices or filters the items array itself.
Fast group toggling
By default a collapsed group unmounts its rows, so re-expanding a group of rich rows (per-row flyouts, links, indicators) re-mounts all of them. Set collapse-mode="hide" to keep the rows mounted and hide them instead, making every toggle instant. Hidden rows are excluded from keyboard navigation and assistive technology, so there is no visible or behavioural change beyond the speed.
Examples
File manager
A data table that is used for file management.
<template>
<FluxPane>
<FluxDataTable
:items="dataSet"
:limits="[]"
:page="1"
:per-page="20"
:total="dataSet.length"
is-hoverable>
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
<FluxTableHeader>Owner</FluxTableHeader>
<FluxTableHeader is-shrinking/>
</template>
<template #name="{item: {name, type}}">
<FluxTableCell>
<FluxFlex :gap="21">
<FluxBoxedIcon name="image"/>
<FluxFlex direction="vertical">
<strong>{{ name }}</strong>
<small>{{ type }}</small>
</FluxFlex>
</FluxFlex>
</FluxTableCell>
</template>
<template #owner="{item: {owner}}">
<FluxTableCell>{{ owner }}</FluxTableCell>
</template>
<template #actions="{}">
<FluxTableCell>
<FluxTableActions>
<FluxAction icon="arrow-down-to-line"/>
<FluxAction icon="ellipsis-h"/>
</FluxTableActions>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxAction, FluxBoxedIcon, FluxDataTable, FluxFlex, FluxPane, FluxTableActions, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { faker } from '@faker-js/faker';
import { computed } from 'vue';
const dataSet = computed(() => Array(5)
.fill(null)
.map((_, index) => ({
id: index,
name: faker.system.commonFileName('png'),
owner: faker.person.fullName(),
type: 'image/png'
})));
</script>Paginated
A data table that is split into pages.
<template>
<FluxPane>
<FluxDataTable
:items="visibleItems"
is-hoverable
:limits="[5, 10, 25, 50, 100]"
:page="page"
:per-page="perPage"
:total="dataSet.length"
@limit="limit => perPage = limit"
@navigate="p => page = p">
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
</template>
<template #name="{item: {name}}">
<FluxTableCell>{{ name }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref, unref } from 'vue';
const page = ref(1);
const perPage = ref(10);
const dataSet = computed(() => Array(500)
.fill(null)
.map((_, index) => ({
id: index,
name: `Item ${index + 1}`
})));
const visibleItems = computed(() => unref(dataSet).slice((unref(page) - 1) * unref(perPage), unref(page) * unref(perPage)) ?? []);
</script>Custom pagination
A data table whose pagination slot renders a compact pager instead of the default bar.
<template>
<FluxPane>
<FluxDataTable
:items="visibleItems"
is-hoverable
:limits="[10]"
:page="page"
:per-page="perPage"
:total="dataSet.length"
@navigate="p => page = p">
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
</template>
<template #name="{item: {name}}">
<FluxTableCell>{{ name }}</FluxTableCell>
</template>
<template #pagination="{total}">
<FluxPagination
arrows
is-compact
:page="page"
:per-page="perPage"
:total="total"
@navigate="p => page = p"/>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxDataTable, FluxPagination, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref, unref } from 'vue';
const page = ref(1);
const perPage = ref(10);
const dataSet = computed(() => Array(500)
.fill(null)
.map((_, index) => ({
id: index,
name: `Item ${index + 1}`
})));
const visibleItems = computed(() => unref(dataSet).slice((unref(page) - 1) * unref(perPage), unref(page) * unref(perPage)) ?? []);
</script>Clickable rows
A data table that emits row-click per row, while ignoring clicks on the action button inside a cell. Focused rows can be walked with the arrow keys and activated with Enter/Space.
<template>
<FluxPane>
<FluxDataTable
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
:total="dataSet.length"
is-hoverable
@row-click="onRowClick">
<template #header>
<FluxTableHeader>
Person
</FluxTableHeader>
<FluxTableHeader>
Role
</FluxTableHeader>
<FluxTableHeader is-shrinking/>
</template>
<template #name="{item: {name, email}}">
<FluxTableCell content-direction="column">
<strong>{{ name }}</strong>
<small>{{ email }}</small>
</FluxTableCell>
</template>
<template #role="{item: {role}}">
<FluxTableCell>
{{ role }}
</FluxTableCell>
</template>
<template #actions="{item}">
<FluxTableCell>
<FluxTableActions>
<FluxSecondaryButton
label="Email"
@click="onEmail(item)"/>
</FluxTableActions>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxDataTable, FluxPane, FluxSecondaryButton, FluxTableActions, FluxTableCell, FluxTableHeader, showSnackbar } from '@flux-ui/components';
import { faker } from '@faker-js/faker';
import { computed } from 'vue';
type Person = {
readonly id: number;
readonly name: string;
readonly email: string;
readonly role: string;
};
const dataSet = computed<Person[]>(() => Array(5)
.fill(null)
.map((_, index) => ({
id: index,
name: faker.person.fullName(),
email: faker.internet.email(),
role: faker.person.jobTitle()
})));
function onRowClick(item: Person, columnIndex: number): void {
showSnackbar({
icon: 'user',
message: `You clicked ${item.name} in column ${columnIndex}.`
});
}
function onEmail(item: Person): void {
showSnackbar({
color: 'info',
icon: 'paper-plane',
message: `Composing an email to ${item.email}.`
});
}
</script>Row colors
A data table that tints rows based on their status.
<template>
<FluxPane>
<FluxDataTable
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
:row-color="getRowColor"
:total="dataSet.length"
is-hoverable>
<template #header>
<FluxTableHeader>
Invoice
</FluxTableHeader>
<FluxTableHeader>
Customer
</FluxTableHeader>
<FluxTableHeader>
Status
</FluxTableHeader>
</template>
<template #number="{item: {number}}">
<FluxTableCell>
{{ number }}
</FluxTableCell>
</template>
<template #customer="{item: {customer}}">
<FluxTableCell>
{{ customer }}
</FluxTableCell>
</template>
<template #status="{item: {status}}">
<FluxTableCell>
{{ status }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import type { FluxColor } from '@flux-ui/types';
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Invoice = {
readonly id: number;
readonly number: string;
readonly customer: string;
readonly status: 'Paid' | 'Open' | 'Expiring soon' | 'Overdue';
};
const dataSet: Invoice[] = [
{id: 1, number: 'INV-2031', customer: 'Alice Johnson', status: 'Paid'},
{id: 2, number: 'INV-2032', customer: 'Bas Milius', status: 'Open'},
{id: 3, number: 'INV-2033', customer: 'Carol White', status: 'Expiring soon'},
{id: 4, number: 'INV-2034', customer: 'Daniel Brown', status: 'Overdue'},
{id: 5, number: 'INV-2035', customer: 'Emma Davis', status: 'Paid'}
];
function getRowColor(item: Invoice): FluxColor | undefined {
switch (item.status) {
case 'Expiring soon':
return 'warning';
case 'Overdue':
return 'danger';
default:
return undefined;
}
}
</script>Selectable rows
A data table where multiple rows can be selected via checkboxes.
<template>
<FluxPane>
<FluxPaneHeader
title="Members"
:subtitle="selected.length === 0 ? 'No rows selected' : `${selected.length} row${selected.length === 1 ? '' : 's'} selected`"/>
<FluxDataTable
v-model:selected="selected"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
selection-mode="multiple"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>
Person
</FluxTableHeader>
<FluxTableHeader is-shrinking>
Status
</FluxTableHeader>
</template>
<template #name="{item: {name, email}}">
<FluxTableCell content-direction="column">
<strong>{{ name }}</strong>
<small>{{ email }}</small>
</FluxTableCell>
</template>
<template #isActive="{item: {isActive}}">
<FluxTableCell>
<FluxBadgeStack>
<FluxBadge
v-if="isActive"
color="success"
icon="circle-check"
label="Active"/>
<FluxBadge
v-else
color="danger"
icon="circle-xmark"
label="Inactive"/>
</FluxBadgeStack>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxBadge, FluxBadgeStack, FluxDataTable, FluxPane, FluxPaneHeader, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { faker } from '@faker-js/faker';
import { computed, ref } from 'vue';
const selected = ref<number[]>([]);
const dataSet = computed(() => Array(5)
.fill(null)
.map((_, index) => ({
id: index,
name: faker.person.fullName(),
email: faker.internet.email(),
isActive: faker.datatype.boolean()
})));
</script>Single selection
A data table where at most one row can be selected.
<template>
<FluxPane>
<FluxPaneHeader
title="Members"
:subtitle="selectedPerson ? `${selectedPerson.name} is selected` : 'No row selected'"/>
<FluxDataTable
v-model:selected="selected"
:items="people"
:limits="[]"
:page="1"
:per-page="5"
selection-mode="single"
:total="people.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>
Person
</FluxTableHeader>
<FluxTableHeader is-shrinking>
Role
</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>{{ item.role }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxPaneHeader, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref } from 'vue';
const selected = ref<number | null>(null);
const people = [
{id: 1, name: 'Ada Lovelace', email: 'ada@example.com', role: 'Lead'},
{id: 2, name: 'Alan Turing', email: 'alan@example.com', role: 'Engineer'},
{id: 3, name: 'Grace Hopper', email: 'grace@example.com', role: 'Engineer'},
{id: 4, name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Manager'},
{id: 5, name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Engineer'}
];
const selectedPerson = computed(() => people.find(person => person.id === selected.value) ?? null);
</script>Selection toolbar
A bar with bulk actions that takes the place of the filter bar while rows are selected.
<template>
<FluxPane>
<FluxDataTable
v-model:selected="selected"
:items="filteredPeople"
:limits="[]"
:page="1"
:per-page="6"
selection-mode="multiple"
:total="filteredPeople.length"
unique-key="id"
is-hoverable>
<template #filter>
<FluxTableBar>
<FluxFilterBar
v-model="filterState"
v-model:search="search"
is-searchable
search-placeholder="Search people...">
<FluxFilterOption
label="Role"
name="role"
:options="roleOptions"/>
</FluxFilterBar>
</FluxTableBar>
</template>
<template #selection="{count, clear}">
<FluxFlexItem :grow="1">
<FluxFlex
align="center"
:gap="9">
<span aria-live="polite">{{ count }} selected</span>
<FluxSpacer/>
<FluxSecondaryButton
icon-leading="trash"
label="Delete"
@click="deleteSelected"/>
<FluxSecondaryButton
label="Clear"
@click="clear()"/>
</FluxFlex>
</FluxFlexItem>
</template>
<template #header>
<FluxTableHeader>Person</FluxTableHeader>
<FluxTableHeader is-shrinking>Role</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>{{ item.role }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxFilterBar, FluxFilterOption, FluxFlex, FluxFlexItem, FluxPane, FluxSecondaryButton, FluxSpacer, FluxTableBar, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import type { FluxFilterOptionItem, FluxFilterState } from '@flux-ui/types';
import { computed, ref } from 'vue';
const roleOptions: FluxFilterOptionItem[] = [
{label: 'Lead', value: 'Lead'},
{label: 'Engineer', value: 'Engineer'},
{label: 'Manager', value: 'Manager'}
];
const search = ref('');
const selected = ref<number[]>([2, 4]);
const filterState = ref<FluxFilterState>({
role: null
});
const people = ref([
{id: 1, name: 'Ada Lovelace', email: 'ada@example.com', role: 'Lead'},
{id: 2, name: 'Alan Turing', email: 'alan@example.com', role: 'Engineer'},
{id: 3, name: 'Grace Hopper', email: 'grace@example.com', role: 'Engineer'},
{id: 4, name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Manager'},
{id: 5, name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Engineer'},
{id: 6, name: 'Radia Perlman', email: 'radia@example.com', role: 'Engineer'}
]);
const filteredPeople = computed(() => people.value.filter(person => {
if (search.value && !person.name.toLowerCase().includes(search.value.toLowerCase())) {
return false;
}
if (filterState.value.role && person.role !== filterState.value.role) {
return false;
}
return true;
}));
function deleteSelected(): void {
people.value = people.value.filter(person => !selected.value.includes(person.id));
selected.value = [];
}
</script>Sortable columns
A data table whose columns drive a single, coordinated client-side sort.
<template>
<FluxPane>
<FluxDataTable
:items="sortedItems"
:limits="[]"
:page="1"
:per-page="people.length"
:total="people.length"
is-hoverable>
<template #header>
<FluxTableHeader
is-sortable
:sort="sortColumn === 'name' ? sortDirection : undefined"
@sort="setSort('name', $event)">
Name
</FluxTableHeader>
<FluxTableHeader
is-sortable
:sort="sortColumn === 'role' ? sortDirection : undefined"
@sort="setSort('role', $event)">
Role
</FluxTableHeader>
<FluxTableHeader
align="end"
is-sortable
:sort="sortColumn === 'commits' ? sortDirection : undefined"
@sort="setSort('commits', $event)">
Commits
</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell>
<strong>{{ item.name }}</strong>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>{{ item.role }}</FluxTableCell>
</template>
<template #commits="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.commits.toLocaleString() }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref } from 'vue';
type SortDirection = 'ascending' | 'descending';
type Person = {
readonly id: number;
readonly name: string;
readonly role: string;
readonly commits: number;
};
const people: Person[] = [
{id: 1, name: 'Ada Lovelace', role: 'Lead', commits: 1287},
{id: 2, name: 'Alan Turing', role: 'Engineer', commits: 942},
{id: 3, name: 'Grace Hopper', role: 'Engineer', commits: 1530},
{id: 4, name: 'Katherine Johnson', role: 'Manager', commits: 318},
{id: 5, name: 'Margaret Hamilton', role: 'Engineer', commits: 2041}
];
const sortColumn = ref<keyof Person | null>('commits');
const sortDirection = ref<SortDirection>('descending');
const sortedItems = computed(() => {
const column = sortColumn.value;
if (!column) {
return people;
}
const factor = sortDirection.value === 'ascending' ? 1 : -1;
return [...people].sort((a, b) => {
const left = a[column];
const right = b[column];
if (typeof left === 'number' && typeof right === 'number') {
return (left - right) * factor;
}
return String(left).localeCompare(String(right)) * factor;
});
});
function setSort(column: keyof Person, direction: SortDirection | null): void {
if (direction === null) {
sortColumn.value = null;
return;
}
sortColumn.value = column;
sortDirection.value = direction;
}
</script>Filtering and search
A data table with a filter bar, client-side filtering, pagination and selection.
<template>
<FluxPane>
<FluxDataTable
v-model:selected="selected"
:items="paginatedItems"
:limits="[5, 10, 25]"
:page="page"
:per-page="perPage"
:total="filteredItems.length"
selection-mode="multiple"
unique-key="id"
is-hoverable
@limit="onLimit"
@navigate="page = $event">
<template #filter>
<FluxTableBar>
<FluxFilterBar
v-model="filterState"
v-model:search="search"
is-searchable
search-placeholder="Search products...">
<FluxFilterOption
icon="circle-check"
label="Status"
name="status"
:options="statusOptions"/>
<FluxFilterOption
icon="clone"
label="Category"
name="category"
:options="categoryOptions"/>
</FluxFilterBar>
</FluxTableBar>
</template>
<template #header>
<FluxTableHeader>Product</FluxTableHeader>
<FluxTableHeader is-shrinking>Status</FluxTableHeader>
<FluxTableHeader
align="end"
is-shrinking>
Price
</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.name }}</strong>
<small>{{ categoryLabels[item.category] }}</small>
</FluxTableCell>
</template>
<template #status="{item}">
<FluxTableCell>
<FluxBadgeStack>
<FluxBadge
:color="item.status === 'active' ? 'success' : 'gray'"
:label="item.status === 'active' ? 'Active' : 'Archived'"/>
</FluxBadgeStack>
</FluxTableCell>
</template>
<template #price="{item}">
<FluxTableCell
align="end"
is-numeric
no-wrap>
{{ priceLabel(item.price) }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxBadgeStack, FluxDataTable, FluxFilterBar, FluxFilterOption, FluxPane, FluxTableBar, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import type { FluxFilterOptionItem, FluxFilterState } from '@flux-ui/types';
import { computed, ref, watch } from 'vue';
type Category = 'lighting' | 'furniture' | 'kitchen';
type Product = {
readonly id: number;
readonly name: string;
readonly category: Category;
readonly status: 'active' | 'archived';
readonly price: number;
};
const categoryLabels: Record<Category, string> = {
lighting: 'Lighting',
furniture: 'Furniture',
kitchen: 'Kitchen'
};
const statusOptions: FluxFilterOptionItem[] = [
{icon: 'circle-check', label: 'Active', value: 'active'},
{icon: 'circle-xmark', label: 'Archived', value: 'archived'}
];
const categoryOptions: FluxFilterOptionItem[] = [
{label: 'Lighting', value: 'lighting'},
{label: 'Furniture', value: 'furniture'},
{label: 'Kitchen', value: 'kitchen'}
];
const products: Product[] = [
{id: 1, name: 'Aurora Lamp', category: 'lighting', status: 'active', price: 4900},
{id: 2, name: 'Borealis Desk', category: 'furniture', status: 'active', price: 32900},
{id: 3, name: 'Cinder Mug', category: 'kitchen', status: 'archived', price: 1450},
{id: 4, name: 'Delta Chair', category: 'furniture', status: 'active', price: 18900},
{id: 5, name: 'Ember Kettle', category: 'kitchen', status: 'active', price: 7900},
{id: 6, name: 'Flare Sconce', category: 'lighting', status: 'archived', price: 6900},
{id: 7, name: 'Glow Pendant', category: 'lighting', status: 'active', price: 12900},
{id: 8, name: 'Harbor Stool', category: 'furniture', status: 'active', price: 9900},
{id: 9, name: 'Iris Teapot', category: 'kitchen', status: 'active', price: 5400},
{id: 10, name: 'Juno Floor Lamp', category: 'lighting', status: 'active', price: 21900},
{id: 11, name: 'Koto Bench', category: 'furniture', status: 'archived', price: 27900},
{id: 12, name: 'Lumen Toaster', category: 'kitchen', status: 'active', price: 8400}
];
const page = ref(1);
const perPage = ref(5);
const search = ref('');
const selected = ref<number[]>([]);
const filterState = ref<FluxFilterState>({
status: null,
category: null
});
const filteredItems = computed(() => products.filter(product => {
if (search.value && !product.name.toLowerCase().includes(search.value.toLowerCase())) {
return false;
}
if (filterState.value.status && product.status !== filterState.value.status) {
return false;
}
if (filterState.value.category && product.category !== filterState.value.category) {
return false;
}
return true;
}));
const paginatedItems = computed(() => {
const start = (page.value - 1) * perPage.value;
return filteredItems.value.slice(start, start + perPage.value);
});
watch([search, filterState], () => {
page.value = 1;
}, {deep: true});
function onLimit(limit: number): void {
perPage.value = limit;
page.value = 1;
}
function priceLabel(price: number): string {
return new Intl.NumberFormat('en', {
currency: 'EUR',
style: 'currency'
}).format(price / 100);
}
</script>Rich cells and row actions
A data table with avatars, badges, progress bars and a per-row actions menu.
<template>
<FluxPane>
<FluxDataTable
:items="members"
:limits="[]"
:page="1"
:per-page="members.length"
:total="members.length"
is-hoverable>
<template #header>
<FluxTableHeader>Member</FluxTableHeader>
<FluxTableHeader is-shrinking>Status</FluxTableHeader>
<FluxTableHeader :min-width="180">Onboarding</FluxTableHeader>
<FluxTableHeader is-shrinking/>
</template>
<template #member="{item}">
<FluxTableCell>
<FluxFlex :gap="15">
<FluxAvatar
:alt="item.name"
:fallback-initials="item.initials"
:size="36"/>
<FluxFlex
direction="vertical"
:gap="0">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxFlex>
</FluxFlex>
</FluxTableCell>
</template>
<template #status="{item}">
<FluxTableCell>
<FluxBadgeStack>
<FluxBadge
v-if="item.isActive"
color="success"
icon="circle-check"
label="Active"/>
<FluxBadge
v-else
color="gray"
icon="circle-xmark"
label="Invited"/>
</FluxBadgeStack>
</FluxTableCell>
</template>
<template #onboarding="{item}">
<FluxTableCell>
<FluxProgressBar
:status="item.step"
:value="item.onboarding"/>
</FluxTableCell>
</template>
<template #actions="{item}">
<FluxTableCell>
<FluxTableActions>
<FluxFlyout>
<template #opener="{open}">
<FluxAction
icon="ellipsis-h"
@click="open()"/>
</template>
<FluxMenu style="width: 180px">
<FluxMenuGroup>
<FluxMenuItem
icon-leading="eye"
label="View profile"
@click="onAction('view', item)"/>
<FluxMenuItem
icon-leading="pen"
label="Edit"
@click="onAction('edit', item)"/>
</FluxMenuGroup>
<FluxSeparator/>
<FluxMenuGroup>
<FluxMenuItem
icon-leading="trash"
is-destructive
label="Remove"
@click="onAction('remove', item)"/>
</FluxMenuGroup>
</FluxMenu>
</FluxFlyout>
</FluxTableActions>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxAction, FluxAvatar, FluxBadge, FluxBadgeStack, FluxDataTable, FluxFlex, FluxFlyout, FluxMenu, FluxMenuGroup, FluxMenuItem, FluxPane, FluxProgressBar, FluxSeparator, FluxTableActions, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Member = {
readonly id: number;
readonly name: string;
readonly initials: string;
readonly email: string;
readonly isActive: boolean;
readonly step: string;
readonly onboarding: number;
};
const members: Member[] = [
{id: 1, name: 'Ada Lovelace', initials: 'AL', email: 'ada@example.com', isActive: true, step: 'Complete', onboarding: 1},
{id: 2, name: 'Alan Turing', initials: 'AT', email: 'alan@example.com', isActive: true, step: 'Training', onboarding: 0.75},
{id: 3, name: 'Grace Hopper', initials: 'GH', email: 'grace@example.com', isActive: false, step: 'Profile setup', onboarding: 0.3},
{id: 4, name: 'Margaret Hamilton', initials: 'MH', email: 'margaret@example.com', isActive: true, step: 'Verification', onboarding: 0.55}
];
function onAction(action: 'view' | 'edit' | 'remove', member: Member): void {
console.log(action, member.name);
}
</script>Expandable rows
A data table where each row can be expanded to reveal detail content.
- Customer
- Acme Inc.
- Items
- 3
- Shipping
- Standard
<template>
<FluxPane>
<FluxDataTable
v-model:expanded="expanded"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>
Order
</FluxTableHeader>
<FluxTableHeader is-shrinking>
Total
</FluxTableHeader>
</template>
<template #reference="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.reference }}</strong>
<small>{{ item.customer }}</small>
</FluxTableCell>
</template>
<template #total="{item}">
<FluxTableCell
is-numeric
no-wrap>
{{ item.total }}
</FluxTableCell>
</template>
<template #expandable="{item}">
<FluxDescriptionList>
<FluxDescriptionItem label="Customer">
{{ item.customer }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Items">
{{ item.items }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Shipping">
{{ item.shipping }}
</FluxDescriptionItem>
</FluxDescriptionList>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxDescriptionItem, FluxDescriptionList, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { ref } from 'vue';
const expanded = ref<number[]>([1]);
const dataSet = [
{id: 1, reference: '#2024-0001', customer: 'Acme Inc.', total: '€ 1,240.00', items: 3, shipping: 'Standard'},
{id: 2, reference: '#2024-0002', customer: 'Globex', total: '€ 320.00', items: 1, shipping: 'Express'},
{id: 3, reference: '#2024-0003', customer: 'Initech', total: '€ 78.50', items: 2, shipping: 'Standard'}
];
</script>Single expansion
A data table where opening a row collapses the previously opened one.
- Customer
- Acme Inc.
- Items
- 3
- Shipping
- Standard
<template>
<FluxPane>
<FluxDataTable
v-model:expanded="expanded"
expand-mode="single"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>
Order
</FluxTableHeader>
<FluxTableHeader is-shrinking>
Total
</FluxTableHeader>
</template>
<template #reference="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.reference }}</strong>
<small>{{ item.customer }}</small>
</FluxTableCell>
</template>
<template #total="{item}">
<FluxTableCell
is-numeric
no-wrap>
{{ item.total }}
</FluxTableCell>
</template>
<template #expandable="{item}">
<FluxDescriptionList>
<FluxDescriptionItem label="Customer">
{{ item.customer }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Items">
{{ item.items }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Shipping">
{{ item.shipping }}
</FluxDescriptionItem>
</FluxDescriptionList>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxDescriptionItem, FluxDescriptionList, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { ref } from 'vue';
const expanded = ref<number[]>([1]);
const dataSet = [
{id: 1, reference: '#2024-0001', customer: 'Acme Inc.', total: '€ 1,240.00', items: 3, shipping: 'Standard'},
{id: 2, reference: '#2024-0002', customer: 'Globex', total: '€ 320.00', items: 1, shipping: 'Express'},
{id: 3, reference: '#2024-0003', customer: 'Initech', total: '€ 78.50', items: 2, shipping: 'Standard'}
];
</script>Conditional expansion
A data table where only rows with detail content show an expand toggle.
- Customer
- Acme Inc.
- Items
- 3
- Shipping
- Standard
<template>
<FluxPane>
<FluxDataTable
v-model:expanded="expanded"
:can-expand="(item) => item.hasDetails"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="5"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>
Order
</FluxTableHeader>
<FluxTableHeader is-shrinking>
Total
</FluxTableHeader>
</template>
<template #reference="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.reference }}</strong>
<small>{{ item.customer }}</small>
</FluxTableCell>
</template>
<template #total="{item}">
<FluxTableCell
is-numeric
no-wrap>
{{ item.total }}
</FluxTableCell>
</template>
<template #expandable="{item}">
<FluxDescriptionList>
<FluxDescriptionItem label="Customer">
{{ item.customer }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Items">
{{ item.items }}
</FluxDescriptionItem>
<FluxDescriptionItem label="Shipping">
{{ item.shipping }}
</FluxDescriptionItem>
</FluxDescriptionList>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxDescriptionItem, FluxDescriptionList, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { ref } from 'vue';
const expanded = ref<number[]>([1]);
const dataSet = [
{id: 1, reference: '#2024-0001', customer: 'Acme Inc.', total: '€ 1,240.00', items: 3, shipping: 'Standard', hasDetails: true},
{id: 2, reference: '#2024-0002', customer: 'Globex', total: '€ 320.00', items: 1, shipping: 'Express', hasDetails: true},
{id: 3, reference: '#2024-0003', customer: 'Initech', total: '€ 78.50', items: 2, shipping: 'Standard', hasDetails: false}
];
</script>Grouped rows
A data table whose rows are grouped under collapsible headers.
<template>
<FluxPane>
<FluxDataTable
v-model:collapsed-groups="collapsedGroups"
:group-by="item => item.team"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="dataSet.length"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
<FluxTableHeader is-shrinking>Role</FluxTableHeader>
</template>
<template #group="{id, items, isExpanded, toggle}">
<FluxTableGroup
:icon="teamIcon[id]"
:is-expanded="isExpanded"
:label="String(id)"
is-expandable
@update:is-expanded="toggle">
<template #after>
<FluxBadge :label="`${items.length}`"/>
</template>
</FluxTableGroup>
</template>
<template #name="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>
{{ item.role }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableGroup, FluxTableHeader } from '@flux-ui/components';
import type { FluxIconName } from '@flux-ui/types';
import { ref } from 'vue';
const teamIcon: Record<string, FluxIconName> = {
Engineering: 'users',
Design: 'palette',
Operations: 'folder'
};
const collapsedGroups = ref<string[]>([]);
const dataSet = [
{id: 1, team: 'Engineering', name: 'Ada Lovelace', email: 'ada@example.com', role: 'Lead'},
{id: 2, team: 'Engineering', name: 'Alan Turing', email: 'alan@example.com', role: 'Engineer'},
{id: 3, team: 'Design', name: 'Grace Hopper', email: 'grace@example.com', role: 'Designer'},
{id: 4, team: 'Operations', name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Manager'},
{id: 5, team: 'Operations', name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Engineer'}
];
</script>Static groups
A data table whose rows are grouped under plain, non-collapsible headers.
<template>
<FluxPane>
<FluxDataTable
:group-by="item => item.team"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="dataSet.length"
:total="dataSet.length"
is-hoverable>
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
<FluxTableHeader is-shrinking>Role</FluxTableHeader>
</template>
<template #group="{id, items}">
<FluxTableGroup :label="String(id)">
<template #after>
<FluxBadge :label="`${items.length}`"/>
</template>
</FluxTableGroup>
</template>
<template #name="{item}">
<FluxTableCell content-direction="column">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>
{{ item.role }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableGroup, FluxTableHeader } from '@flux-ui/components';
const dataSet = [
{id: 1, team: 'Engineering', name: 'Ada Lovelace', email: 'ada@example.com', role: 'Lead'},
{id: 2, team: 'Engineering', name: 'Alan Turing', email: 'alan@example.com', role: 'Engineer'},
{id: 3, team: 'Design', name: 'Grace Hopper', email: 'grace@example.com', role: 'Designer'},
{id: 4, team: 'Operations', name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Manager'},
{id: 5, team: 'Operations', name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Engineer'}
];
</script>Wide table
A wide data table with multiple pinned columns on the left and right edges.
<template>
<FluxPane>
<FluxDataTable
:items="items"
:limits="[]"
:page="1"
:per-page="items.length"
:total="items.length"
is-hoverable>
<template #header>
<FluxTableHeader
pinned
:min-width="180">
Product
</FluxTableHeader>
<FluxTableHeader
pinned
:min-width="150">
SKU
</FluxTableHeader>
<FluxTableHeader :min-width="150">Category</FluxTableHeader>
<FluxTableHeader :min-width="180">Supplier</FluxTableHeader>
<FluxTableHeader
align="end"
:min-width="120">
Stock
</FluxTableHeader>
<FluxTableHeader
align="end"
pinned="end"
:min-width="120">
Price
</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell>
<strong>{{ item.name }}</strong>
</FluxTableCell>
</template>
<template #sku="{item}">
<FluxTableCell>{{ item.sku }}</FluxTableCell>
</template>
<template #category="{item}">
<FluxTableCell>{{ item.category }}</FluxTableCell>
</template>
<template #supplier="{item}">
<FluxTableCell>{{ item.supplier }}</FluxTableCell>
</template>
<template #stock="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.stock }}
</FluxTableCell>
</template>
<template #price="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.price }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
const items = [
{id: 1, name: 'Aurora Lamp', sku: 'AUR-001', category: 'Lighting', supplier: 'Lumen Co.', stock: 128, price: '€ 49.00'},
{id: 2, name: 'Borealis Desk', sku: 'BOR-014', category: 'Furniture', supplier: 'Nordic Works', stock: 12, price: '€ 329.00'},
{id: 3, name: 'Cinder Mug', sku: 'CIN-220', category: 'Kitchen', supplier: 'Clayworks', stock: 1043, price: '€ 14.50'},
{id: 4, name: 'Delta Chair', sku: 'DEL-067', category: 'Furniture', supplier: 'Nordic Works', stock: 47, price: '€ 189.00'},
{id: 5, name: 'Ember Kettle', sku: 'EMB-309', category: 'Kitchen', supplier: 'Clayworks', stock: 6, price: '€ 79.00'}
];
</script>Pinned with selection
A data table where pinning the first column keeps the selection column pinned alongside it.
<template>
<FluxPane>
<FluxDataTable
v-model:selected="selected"
:items="dataSet"
:limits="[]"
:page="1"
:per-page="dataSet.length"
selection-mode="multiple"
:total="dataSet.length"
unique-key="id"
is-hoverable>
<template #header>
<FluxTableHeader
pinned
:min-width="200">
Person
</FluxTableHeader>
<FluxTableHeader :min-width="160">Role</FluxTableHeader>
<FluxTableHeader :min-width="170">Department</FluxTableHeader>
<FluxTableHeader :min-width="170">Location</FluxTableHeader>
<FluxTableHeader :min-width="150">Start date</FluxTableHeader>
<FluxTableHeader
align="end"
pinned="end"
:min-width="150">
Salary
</FluxTableHeader>
</template>
<template #name="{item: {name, email}}">
<FluxTableCell content-direction="column">
<strong>{{ name }}</strong>
<small>{{ email }}</small>
</FluxTableCell>
</template>
<template #role="{item: {role}}">
<FluxTableCell>{{ role }}</FluxTableCell>
</template>
<template #department="{item: {department}}">
<FluxTableCell>{{ department }}</FluxTableCell>
</template>
<template #location="{item: {location}}">
<FluxTableCell>{{ location }}</FluxTableCell>
</template>
<template #startDate="{item: {startDate}}">
<FluxTableCell>{{ startDate }}</FluxTableCell>
</template>
<template #salary="{item: {salary}}">
<FluxTableCell
align="end"
is-numeric>
{{ salary }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
lang="ts"
setup>
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { faker } from '@faker-js/faker';
import { computed, ref } from 'vue';
const selected = ref<number[]>([]);
const dataSet = computed(() => Array(6)
.fill(null)
.map((_, index) => ({
id: index,
name: faker.person.fullName(),
email: faker.internet.email(),
role: faker.person.jobTitle(),
department: faker.commerce.department(),
location: `${faker.location.city()}, ${faker.location.countryCode()}`,
startDate: faker.date.past().toLocaleDateString('en-US', {year: 'numeric', month: 'short', day: 'numeric'}),
salary: `€ ${faker.number.int({min: 42, max: 128}) * 1000}`
})));
</script>Column sizing
Headers mixing a fixed width, a min/max range and a shrinking column.
<template>
<FluxPane>
<FluxDataTable
:items="products"
:limits="[]"
:page="1"
:per-page="products.length"
:total="products.length"
is-hoverable>
<template #header>
<FluxTableHeader :width="120">SKU</FluxTableHeader>
<FluxTableHeader>Product</FluxTableHeader>
<FluxTableHeader
:min-width="120"
:max-width="220">
Category
</FluxTableHeader>
<FluxTableHeader
align="end"
is-shrinking>
Price
</FluxTableHeader>
<FluxTableHeader is-shrinking>Status</FluxTableHeader>
</template>
<template #sku="{item}">
<FluxTableCell>{{ item.sku }}</FluxTableCell>
</template>
<template #name="{item}">
<FluxTableCell>
<strong>{{ item.name }}</strong>
</FluxTableCell>
</template>
<template #category="{item}">
<FluxTableCell>{{ item.category }}</FluxTableCell>
</template>
<template #price="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ formatCurrency(item.price) }}
</FluxTableCell>
</template>
<template #status="{item}">
<FluxTableCell>
<FluxBadge
:color="item.stock > 0 ? 'success' : 'gray'"
:label="item.stock > 0 ? 'In stock' : 'Sold out'"/>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Product = {
readonly id: number;
readonly sku: string;
readonly name: string;
readonly category: string;
readonly price: number;
readonly stock: number;
};
const products: Product[] = [
{id: 1, sku: 'AC-1024', name: 'Aurora Ceiling Light', category: 'Lighting', price: 4900, stock: 34},
{id: 2, sku: 'FD-2087', name: 'Borealis Standing Desk', category: 'Furniture', price: 32900, stock: 8},
{id: 3, sku: 'KD-3391', name: 'Cinder Ceramic Mug', category: 'Kitchen & dining', price: 1450, stock: 0},
{id: 4, sku: 'FD-4410', name: 'Delta Lounge Chair', category: 'Furniture', price: 18900, stock: 15},
{id: 5, sku: 'KD-5502', name: 'Ember Electric Kettle', category: 'Kitchen & dining', price: 7900, stock: 21}
];
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en', {
currency: 'EUR',
style: 'currency'
}).format(value / 100);
}
</script>Numeric columns
Right-aligned numeric columns with sortable numeric and date headers.
<template>
<FluxPane>
<FluxDataTable
:items="sortedItems"
:limits="[]"
:page="1"
:per-page="transactions.length"
:total="transactions.length"
is-hoverable>
<template #header>
<FluxTableHeader
data-type="date"
is-numeric
is-shrinking
is-sortable
no-wrap
:sort="sortColumn === 'date' ? sortDirection : undefined"
@sort="setSort('date', $event)">
Date
</FluxTableHeader>
<FluxTableHeader :min-width="200">Description</FluxTableHeader>
<FluxTableHeader is-shrinking>Category</FluxTableHeader>
<FluxTableHeader
align="end"
data-type="numeric"
is-numeric
is-sortable
no-wrap
:sort="sortColumn === 'amount' ? sortDirection : undefined"
@sort="setSort('amount', $event)">
Amount
</FluxTableHeader>
</template>
<template #date="{item}">
<FluxTableCell>{{ item.date }}</FluxTableCell>
</template>
<template #description="{item}">
<FluxTableCell>{{ item.description }}</FluxTableCell>
</template>
<template #category="{item}">
<FluxTableCell>
<FluxBadge
color="gray"
:label="item.category"/>
</FluxTableCell>
</template>
<template #amount="{item}">
<FluxTableCell>{{ formatCurrency(item.amount) }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref } from 'vue';
type SortDirection = 'ascending' | 'descending';
type SortColumn = 'date' | 'amount';
type Transaction = {
readonly id: number;
readonly date: string;
readonly order: number;
readonly description: string;
readonly category: string;
readonly amount: number;
};
const transactions: Transaction[] = [
{id: 1, date: 'Feb 2', order: 20260202, description: 'Invoice #10241 — Lumen Co.', category: 'Income', amount: 4800},
{id: 2, date: 'Feb 5', order: 20260205, description: 'Cloud hosting', category: 'Expense', amount: -320},
{id: 3, date: 'Feb 11', order: 20260211, description: 'Invoice #10243 — Clayworks', category: 'Income', amount: 6120},
{id: 4, date: 'Feb 14', order: 20260214, description: 'Design software licences', category: 'Expense', amount: -540},
{id: 5, date: 'Feb 22', order: 20260222, description: 'Refund — Harbor Studio', category: 'Refund', amount: -189}
];
const sortColumn = ref<SortColumn | null>('date');
const sortDirection = ref<SortDirection>('descending');
const sortedItems = computed(() => {
const column = sortColumn.value;
if (!column) {
return transactions;
}
const factor = sortDirection.value === 'ascending' ? 1 : -1;
const key = column === 'date' ? 'order' : 'amount';
return [...transactions].sort((a, b) => (a[key] - b[key]) * factor);
});
function setSort(column: SortColumn, direction: SortDirection | null): void {
if (direction === null) {
sortColumn.value = null;
return;
}
sortColumn.value = column;
sortDirection.value = direction;
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en', {
currency: 'EUR',
signDisplay: 'always',
style: 'currency'
}).format(value);
}
</script>Summary footer
A footer row that sums the page across spanning cells.
<template>
<FluxPane>
<FluxDataTable
:items="orders"
:limits="[]"
:page="1"
:per-page="orders.length"
:total="orders.length"
is-hoverable>
<template #header>
<FluxTableHeader>Order</FluxTableHeader>
<FluxTableHeader :min-width="180">Customer</FluxTableHeader>
<FluxTableHeader
align="end"
is-shrinking>
Items
</FluxTableHeader>
<FluxTableHeader
align="end"
:min-width="120">
Total
</FluxTableHeader>
</template>
<template #reference="{item}">
<FluxTableCell>
<strong>{{ item.reference }}</strong>
</FluxTableCell>
</template>
<template #customer="{item}">
<FluxTableCell>{{ item.customer }}</FluxTableCell>
</template>
<template #items="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.items }}
</FluxTableCell>
</template>
<template #total="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ formatCurrency(item.total) }}
</FluxTableCell>
</template>
<template #footer>
<FluxTableCell
align="end"
:colspan="3">
<strong>Page total</strong>
</FluxTableCell>
<FluxTableCell
align="end"
is-numeric>
<strong>{{ formatCurrency(pageTotal) }}</strong>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed } from 'vue';
type Order = {
readonly id: number;
readonly reference: string;
readonly customer: string;
readonly items: number;
readonly total: number;
};
const orders: Order[] = [
{id: 1, reference: '#10241', customer: 'Lumen Co.', items: 4, total: 48000},
{id: 2, reference: '#10242', customer: 'Nordic Works', items: 2, total: 32500},
{id: 3, reference: '#10243', customer: 'Clayworks', items: 6, total: 61200},
{id: 4, reference: '#10244', customer: 'Harbor Studio', items: 1, total: 18900}
];
const pageTotal = computed(() => orders.reduce((sum, order) => sum + order.total, 0));
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en', {
currency: 'EUR',
style: 'currency'
}).format(value);
}
</script>Fixed-width columns
A weekly timesheet with fixed-width day columns and a total.
<template>
<FluxPane>
<FluxDataTable
:items="rows"
:limits="[]"
:page="1"
:per-page="rows.length"
:total="rows.length"
is-hoverable>
<template #header>
<FluxTableHeader :min-width="160">Member</FluxTableHeader>
<FluxTableHeader
align="end"
:width="66">
Mon
</FluxTableHeader>
<FluxTableHeader
align="end"
:width="66">
Tue
</FluxTableHeader>
<FluxTableHeader
align="end"
:width="66">
Wed
</FluxTableHeader>
<FluxTableHeader
align="end"
:width="66">
Thu
</FluxTableHeader>
<FluxTableHeader
align="end"
:width="66">
Fri
</FluxTableHeader>
<FluxTableHeader
align="end"
:width="78">
Total
</FluxTableHeader>
</template>
<template #member="{item}">
<FluxTableCell no-wrap>{{ item.member }}</FluxTableCell>
</template>
<template #mon="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.hours.mon }}
</FluxTableCell>
</template>
<template #tue="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.hours.tue }}
</FluxTableCell>
</template>
<template #wed="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.hours.wed }}
</FluxTableCell>
</template>
<template #thu="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.hours.thu }}
</FluxTableCell>
</template>
<template #fri="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.hours.fri }}
</FluxTableCell>
</template>
<template #total="{item}">
<FluxTableCell
align="end"
is-numeric>
<strong>{{ weekTotal(item) }}</strong>
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Hours = {
readonly mon: number;
readonly tue: number;
readonly wed: number;
readonly thu: number;
readonly fri: number;
};
type Row = {
readonly id: number;
readonly member: string;
readonly hours: Hours;
};
const rows: Row[] = [
{id: 1, member: 'Ada Lovelace', hours: {mon: 8, tue: 8, wed: 7, thu: 8, fri: 6}},
{id: 2, member: 'Alan Turing', hours: {mon: 7, tue: 8, wed: 8, thu: 8, fri: 8}},
{id: 3, member: 'Grace Hopper', hours: {mon: 8, tue: 6, wed: 8, thu: 4, fri: 8}},
{id: 4, member: 'Katherine Johnson', hours: {mon: 4, tue: 8, wed: 8, thu: 8, fri: 5}}
];
function weekTotal(row: Row): number {
return row.hours.mon + row.hours.tue + row.hours.wed + row.hours.thu + row.hours.fri;
}
</script>Stacked cells
Cells that stack a primary and secondary line of content.
<template>
<FluxPane>
<FluxDataTable
:items="customers"
:limits="[]"
:page="1"
:per-page="customers.length"
:total="customers.length"
is-hoverable>
<template #header>
<FluxTableHeader :min-width="220">Customer</FluxTableHeader>
<FluxTableHeader :min-width="160">Company</FluxTableHeader>
<FluxTableHeader is-shrinking>Plan</FluxTableHeader>
<FluxTableHeader is-shrinking>Since</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell
content-direction="column"
:content-gap="3">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #company="{item}">
<FluxTableCell>{{ item.company }}</FluxTableCell>
</template>
<template #plan="{item}">
<FluxTableCell>
<FluxBadge
:color="item.plan === 'Enterprise' ? 'primary' : 'gray'"
:label="item.plan"/>
</FluxTableCell>
</template>
<template #since="{item}">
<FluxTableCell no-wrap>{{ item.since }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Customer = {
readonly id: number;
readonly name: string;
readonly email: string;
readonly company: string;
readonly plan: 'Starter' | 'Growth' | 'Enterprise';
readonly since: string;
};
const customers: Customer[] = [
{id: 1, name: 'Mila Vega', email: 'mila@lumen.co', company: 'Lumen Co.', plan: 'Enterprise', since: '2021'},
{id: 2, name: 'Sven Halloran', email: 'sven@nordicworks.io', company: 'Nordic Works', plan: 'Growth', since: '2022'},
{id: 3, name: 'Priya Nandal', email: 'priya@clayworks.com', company: 'Clayworks', plan: 'Enterprise', since: '2020'},
{id: 4, name: 'Theo Marsh', email: 'theo@harbor.studio', company: 'Harbor Studio', plan: 'Starter', since: '2024'}
];
</script>Wrapping content
A wrapping subject column whose meta columns stay on a single line.
<template>
<FluxPane>
<FluxDataTable
:items="tickets"
:limits="[]"
:page="1"
:per-page="tickets.length"
:total="tickets.length"
is-hoverable>
<template #header>
<FluxTableHeader :width="90">Ticket</FluxTableHeader>
<FluxTableHeader :min-width="260">Subject</FluxTableHeader>
<FluxTableHeader is-shrinking>Status</FluxTableHeader>
<FluxTableHeader is-shrinking>Updated</FluxTableHeader>
</template>
<template #reference="{item}">
<FluxTableCell no-wrap>
<strong>{{ item.reference }}</strong>
</FluxTableCell>
</template>
<template #subject="{item}">
<FluxTableCell
content-direction="column"
:content-gap="3">
<strong>{{ item.subject }}</strong>
<small>{{ item.excerpt }}</small>
</FluxTableCell>
</template>
<template #status="{item}">
<FluxTableCell>
<FluxBadge
:color="statusColor[item.status]"
:label="item.status"/>
</FluxTableCell>
</template>
<template #updated="{item}">
<FluxTableCell no-wrap>{{ item.updated }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import type { FluxColor } from '@flux-ui/types';
type Status = 'Open' | 'Pending' | 'Resolved';
type Ticket = {
readonly id: number;
readonly reference: string;
readonly subject: string;
readonly excerpt: string;
readonly status: Status;
readonly updated: string;
};
const statusColor: Record<Status, FluxColor> = {
Open: 'primary',
Pending: 'warning',
Resolved: 'success'
};
const tickets: Ticket[] = [
{id: 1, reference: 'SUP-482', subject: 'Column widths jump when the data table loads', excerpt: 'The layout briefly shows equal columns before the grid template resolves, which looks like a flash on slower connections.', status: 'Open', updated: 'Mar 2'},
{id: 2, reference: 'SUP-479', subject: 'Sticky header overlaps the filter bar on Safari', excerpt: 'When scrolling quickly the header and the table bar overlap by a few pixels only in Safari 17.', status: 'Pending', updated: 'Mar 1'},
{id: 3, reference: 'SUP-471', subject: 'Pinned column shadow missing in dark mode', excerpt: 'The edge shadow that indicates a pinned column is not visible against the dark surface token.', status: 'Resolved', updated: 'Feb 27'}
];
</script>Consistent height
A partial page that keeps a fixed height with filler rows.
<template>
<FluxPane>
<FluxDataTable
:items="visibleItems"
:limits="[6, 12]"
:page="page"
:per-page="perPage"
:total="inventory.length"
is-filled
is-hoverable
style="min-height: 420px"
@limit="onLimit"
@navigate="page = $event">
<template #header>
<FluxTableHeader :width="90">SKU</FluxTableHeader>
<FluxTableHeader>Item</FluxTableHeader>
<FluxTableHeader is-shrinking>Location</FluxTableHeader>
<FluxTableHeader
align="end"
is-shrinking>
Quantity
</FluxTableHeader>
</template>
<template #sku="{item}">
<FluxTableCell no-wrap>{{ item.sku }}</FluxTableCell>
</template>
<template #name="{item}">
<FluxTableCell>{{ item.name }}</FluxTableCell>
</template>
<template #location="{item}">
<FluxTableCell no-wrap>{{ item.location }}</FluxTableCell>
</template>
<template #quantity="{item}">
<FluxTableCell
align="end"
is-numeric>
{{ item.quantity }}
</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
import { computed, ref } from 'vue';
type InventoryItem = {
readonly id: number;
readonly sku: string;
readonly name: string;
readonly location: string;
readonly quantity: number;
};
const inventory: InventoryItem[] = [
{id: 1, sku: 'A-01', name: 'Aurora Ceiling Light', location: 'Aisle 1 · Bay A', quantity: 34},
{id: 2, sku: 'A-02', name: 'Borealis Standing Desk', location: 'Aisle 1 · Bay C', quantity: 8},
{id: 3, sku: 'A-03', name: 'Cinder Ceramic Mug', location: 'Aisle 2 · Bay A', quantity: 120},
{id: 4, sku: 'A-04', name: 'Delta Lounge Chair', location: 'Aisle 2 · Bay D', quantity: 15},
{id: 5, sku: 'A-05', name: 'Ember Electric Kettle', location: 'Aisle 3 · Bay B', quantity: 21},
{id: 6, sku: 'A-06', name: 'Flare Wall Sconce', location: 'Aisle 3 · Bay E', quantity: 47},
{id: 7, sku: 'A-07', name: 'Glow Pendant Lamp', location: 'Aisle 4 · Bay A', quantity: 12},
{id: 8, sku: 'A-08', name: 'Harbor Bar Stool', location: 'Aisle 4 · Bay C', quantity: 3}
];
const page = ref(1);
const perPage = ref(6);
const visibleItems = computed(() => inventory.slice((page.value - 1) * perPage.value, page.value * perPage.value));
function onLimit(limit: number): void {
perPage.value = limit;
page.value = 1;
}
</script>Sticky header
A long list whose header and bar stick while scrolling.
<template>
<FluxPane>
<FluxDataTable
:items="events"
:limits="[]"
:page="1"
:per-page="events.length"
:total="events.length"
is-hoverable
is-sticky
style="max-height: 360px">
<template #filter>
<FluxTableBar>
<FluxFlex
align="center"
justify="between">
<strong>Activity log</strong>
<FluxBadge :label="`${events.length} events`"/>
</FluxFlex>
</FluxTableBar>
</template>
<template #header>
<FluxTableHeader is-shrinking>Time</FluxTableHeader>
<FluxTableHeader :min-width="180">Actor</FluxTableHeader>
<FluxTableHeader :min-width="220">Event</FluxTableHeader>
</template>
<template #time="{item}">
<FluxTableCell
is-numeric
no-wrap>
{{ item.time }}
</FluxTableCell>
</template>
<template #actor="{item}">
<FluxTableCell no-wrap>{{ item.actor }}</FluxTableCell>
</template>
<template #event="{item}">
<FluxTableCell>{{ item.event }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxFlex, FluxPane, FluxTableBar, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Event = {
readonly id: number;
readonly time: string;
readonly actor: string;
readonly event: string;
};
const actors = ['Mila Vega', 'Sven Halloran', 'Priya Nandal', 'Theo Marsh', 'Lena Ortiz'];
const actions = [
'signed in',
'updated the invoice #10241',
'archived a product',
'invited a team member',
'exported the customer list',
'changed the billing plan'
];
const events: Event[] = Array.from({length: 40}, (_, index) => {
const minutes = index * 7;
const hours = 9 + Math.floor(minutes / 60);
const mins = minutes % 60;
return {
id: index + 1,
time: `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`,
actor: actors[index % actors.length],
event: actions[index % actions.length]
};
});
</script>Sticky groups
Grouped rows whose header sticks while scrolling through the groups.
<template>
<FluxPane>
<FluxDataTable
v-model:collapsed-groups="collapsedGroups"
:group-by="item => item.team"
:items="members"
:limits="[]"
:page="1"
:per-page="members.length"
:total="members.length"
unique-key="id"
is-hoverable
is-sticky
style="max-height: 360px">
<template #header>
<FluxTableHeader :min-width="220">Name</FluxTableHeader>
<FluxTableHeader :min-width="160">Role</FluxTableHeader>
<FluxTableHeader is-shrinking>Location</FluxTableHeader>
</template>
<template #group="{id, items, isExpanded, toggle}">
<FluxTableGroup
:icon="teamIcon[id]"
:is-expanded="isExpanded"
:label="String(id)"
is-expandable
@update:is-expanded="toggle">
<template #after>
<FluxBadge :label="`${items.length}`"/>
</template>
</FluxTableGroup>
</template>
<template #name="{item}">
<FluxTableCell
content-direction="column"
:content-gap="3">
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>{{ item.role }}</FluxTableCell>
</template>
<template #location="{item}">
<FluxTableCell no-wrap>{{ item.location }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxBadge, FluxDataTable, FluxPane, FluxTableCell, FluxTableGroup, FluxTableHeader } from '@flux-ui/components';
import type { FluxIconName } from '@flux-ui/types';
import { ref } from 'vue';
type Member = {
readonly id: number;
readonly team: string;
readonly name: string;
readonly email: string;
readonly role: string;
readonly location: string;
};
const teamIcon: Record<string, FluxIconName> = {
Engineering: 'users',
Design: 'palette',
Operations: 'folder',
Sales: 'chart-line'
};
const members: Member[] = [
{id: 1, team: 'Engineering', name: 'Ada Lovelace', email: 'ada@example.com', role: 'Lead', location: 'London'},
{id: 2, team: 'Engineering', name: 'Alan Turing', email: 'alan@example.com', role: 'Engineer', location: 'Manchester'},
{id: 3, team: 'Engineering', name: 'Linus Powell', email: 'linus@example.com', role: 'Engineer', location: 'Berlin'},
{id: 4, team: 'Engineering', name: 'Nadia Reyes', email: 'nadia@example.com', role: 'Engineer', location: 'Madrid'},
{id: 5, team: 'Design', name: 'Grace Hopper', email: 'grace@example.com', role: 'Lead designer', location: 'New York'},
{id: 6, team: 'Design', name: 'Theo Marsh', email: 'theo@example.com', role: 'Product designer', location: 'Toronto'},
{id: 7, team: 'Design', name: 'Lena Ortiz', email: 'lena@example.com', role: 'Brand designer', location: 'Lisbon'},
{id: 8, team: 'Operations', name: 'Katherine Johnson', email: 'katherine@example.com', role: 'Manager', location: 'Hampton'},
{id: 9, team: 'Operations', name: 'Margaret Hamilton', email: 'margaret@example.com', role: 'Analyst', location: 'Boston'},
{id: 10, team: 'Operations', name: 'Sven Halloran', email: 'sven@example.com', role: 'Coordinator', location: 'Oslo'},
{id: 11, team: 'Sales', name: 'Mila Vega', email: 'mila@example.com', role: 'Account lead', location: 'Amsterdam'},
{id: 12, team: 'Sales', name: 'Priya Nandal', email: 'priya@example.com', role: 'Account manager', location: 'Mumbai'},
{id: 13, team: 'Sales', name: 'Oscar Bright', email: 'oscar@example.com', role: 'Sales rep', location: 'Chicago'}
];
const collapsedGroups = ref<string[]>([]);
</script>Skeleton loading
A data table whose loading slot replaces the spinner with skeleton rows.
<template>
<FluxPane>
<FluxPaneHeader title="Members">
<template #after>
<FluxSecondaryButton
:disabled="isLoading"
icon-leading="rotate"
label="Refresh"
@click="refresh"/>
</template>
</FluxPaneHeader>
<FluxDataTable
:is-loading="isLoading"
:items="people"
:limits="[]"
:page="1"
:per-page="4"
:total="people.length"
is-hoverable>
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
<FluxTableHeader>Role</FluxTableHeader>
<FluxTableHeader is-shrinking>Commits</FluxTableHeader>
</template>
<template #loading="{perPage}">
<FluxTableRow
v-for="row in perPage"
:key="row">
<FluxTableCell
v-for="cell in 3"
:key="cell">
<FluxSkeleton/>
</FluxTableCell>
</FluxTableRow>
</template>
<template #name="{item}">
<FluxTableCell>{{ item.name }}</FluxTableCell>
</template>
<template #role="{item}">
<FluxTableCell>{{ item.role }}</FluxTableCell>
</template>
<template #commits="{item}">
<FluxTableCell is-numeric>{{ item.commits }}</FluxTableCell>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxPane, FluxPaneHeader, FluxSecondaryButton, FluxSkeleton, FluxTableCell, FluxTableHeader, FluxTableRow } from '@flux-ui/components';
import { ref } from 'vue';
const isLoading = ref(false);
const people = [
{id: 1, name: 'Ada Lovelace', role: 'Lead', commits: 1287},
{id: 2, name: 'Alan Turing', role: 'Engineer', commits: 942},
{id: 3, name: 'Grace Hopper', role: 'Engineer', commits: 1530},
{id: 4, name: 'Margaret Hamilton', role: 'Engineer', commits: 2041}
];
function refresh(): void {
isLoading.value = true;
setTimeout(() => isLoading.value = false, 2000);
}
</script>Empty
A data table that shows a custom message when there is no data.
<template>
<FluxPane>
<FluxDataTable
:items="items"
:limits="[]"
:page="1"
:per-page="5"
:total="0">
<template #header>
<FluxTableHeader>Name</FluxTableHeader>
<FluxTableHeader>Email</FluxTableHeader>
</template>
<template #name="{item}">
<FluxTableCell>{{ item.name }}</FluxTableCell>
</template>
<template #email="{item}">
<FluxTableCell>{{ item.email }}</FluxTableCell>
</template>
<template #empty>
<FluxFlex
align="center"
direction="vertical"
:gap="9"
justify="center"
style="padding: 45px 15px">
<FluxIcon
name="users"
:size="24"/>
<span>No customers found.</span>
</FluxFlex>
</template>
</FluxDataTable>
</FluxPane>
</template>
<script
setup
lang="ts">
import { FluxDataTable, FluxFlex, FluxIcon, FluxPane, FluxTableCell, FluxTableHeader } from '@flux-ui/components';
type Customer = {
readonly id: number;
readonly name: string;
readonly email: string;
};
const items: Customer[] = [];
</script>