Skip to content

Table toolbar

Keep search, filters and actions together above the table they affect.

InvoiceClientIssuedStatus
Northwind TradersJul 14, 2026Paid
Blue Yonder AirlinesAug 03, 2026Due
ContosoAug 21, 2026Overdue
FabrikamSep 02, 2026Paid
Tailspin ToysSep 18, 2026Due
<script setup lang="ts">import { computed, ref } from 'vue'import { Plus, Settings2 } from '@lucide/vue'import type { DateRangeValue, TableColumn } from 'grani'import { UiCard, UiTableToolbar, UiDateRange, UiIconButton, UiButton, UiTable, UiCopyButton, UiDateCell, UiBadge } from 'grani' interface Invoice {  id: number  number: string  client: string  status: 'paid' | 'due' | 'overdue'  issued: string} const ROWS: Invoice[] = [  { id: 1, number: 'INV-1041', client: 'Northwind Traders', status: 'paid', issued: '2026-07-14' },  {    id: 2,    number: 'INV-1042',    client: 'Blue Yonder Airlines',    status: 'due',    issued: '2026-08-03',  },  { id: 3, number: 'INV-1043', client: 'Contoso', status: 'overdue', issued: '2026-08-21' },  { id: 4, number: 'INV-1044', client: 'Fabrikam', status: 'paid', issued: '2026-09-02' },  { id: 5, number: 'INV-1045', client: 'Tailspin Toys', status: 'due', issued: '2026-09-18' },] const columns: TableColumn<Invoice>[] = [  { key: 'number', label: 'Invoice', width: '120px' },  { key: 'client', label: 'Client', truncate: true },  { key: 'issued', label: 'Issued', width: '130px' },  { key: 'status', label: 'Status', width: '110px' },] const TONE = { paid: 'success', due: 'neutral', overdue: 'danger' } as const const LABEL = { paid: 'Paid', due: 'Due', overdue: 'Overdue' } as const // Each tab keeps its own search: typing in one does not change the others.const query = ref('') const period = ref<DateRangeValue>({ from: null, to: null, preset: 'all' }) const byClient = (q: string) => (r: Invoice) =>  !q || r.client.toLowerCase().includes(q.toLowerCase()) const rows = computed(() =>  ROWS.filter(    (r) =>      (!period.value.from || r.issued >= period.value.from) &&      (!period.value.to || r.issued <= period.value.to) &&      byClient(query.value)(r),  ),)</script> <template>  <UiCard :padded="false" class="w-full">    <UiTableToolbar      v-model:search="query"      search-placeholder="Search by client"      class="border-b border-border"    >      <template #start="{ size }">        <UiDateRange v-model="period" ariaLabel="Issued" :size="size" />      </template>      <template #end="{ size }">        <UiIconButton :icon="Settings2" ariaLabel="Categories" :size="size" />        <UiButton :size="size"><Plus />New invoice</UiButton>      </template>    </UiTableToolbar>    <UiTable      :columns="columns"      :items="rows"      :filtered="Boolean(query || period.from || period.to)"    >      <template #cell.number="{ value }">        <UiCopyButton size="xs" flush :value="String(value)" :label="String(value)" />      </template>      <template #cell.issued="{ value }">        <UiDateCell :value="String(value)" />      </template>      <template #cell.status="{ value }">        <UiBadge :tone="TONE[value as Invoice['status']]" size="sm">          {{ LABEL[value as Invoice['status']] }}        </UiBadge>      </template>    </UiTable>  </UiCard></template>

Also called list toolbar, search and filters bar, table header bar.

Usage

<UiTableToolbar v-model:search="search" />

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
search*stringThe search text (v-model:search); the field is always there. Sent as typed, spaces included, after a 250 ms pause or at Enter.
searchPlaceholderstringWhat the search looks in; the field's placeholder and its name.
columnsTableColumns<T>The columns menu: the state from useTableColumns. The button stands first in the right cluster; without it the columns are fixed.
sizexs | sm | mdStep of the control ladder for the row: xs for a dense row, sm (36 px), md for a comfortable one; unset, the app's size (createGrani({ size })). The slots get it as { size }.

Anything else lands on the root element: native attributes, aria-*, data-testid.

When to use another component

When you needTake
One block of table, toolbar, pages and statesData table
A search outside a listInput type="search"
A second columns menuNever in #end; the toolbar draws it from columns