List
Composition-based list primitives under the frappe-ui/list subpath. Every list surface is one column grid: a feed row is just the default column template, a table is an explicit one. The family owns geometry — columns, dividers, hover surfaces, selection and sort chrome — and nothing readable: cell contents (typography, avatars, badges, unread emphasis) are entirely app-authored.
Not to be confused with the config-driven ListView in frappe-ui/experimental — that stays untouched; import from frappe-ui/list for this family.
Feed mode
The default template (auto, minmax(0,1fr), auto) fits leading media, content, and a right-aligned trailing cell. Rows with route render as RouterLinks, rows with href as native same-tab anchors, and rows with a click listener as buttons — real interactive elements, so a row is clickable or carries inline action buttons, never both (nested interactive controls are invalid HTML); row actions shows how to combine them. selectable reveals the animated checkbox column and switches row click from navigate to toggle; selected values surface through v-model:selection. When a ListHeader is present, a select-all checkbox appears in it automatically — checked when every row is selected, mixed when only some are, and toggling all rows on or off. It reasons over the full ListRows items, so it covers virtualized rows too. ListRows resolves each row's identity once and exposes it as the scoped value prop. The identity defaults to the item's name/id; pass row-key (a field name or (item, index) => key) when the row should use a different field.
Dividers default to inset: they start at the content column (the text edge) by construction, never render above the first row, and hide around a hovered row so the rounded hover surface floats free.
<script setup lang="ts">
import { ref } from 'vue'
import { Avatar, Badge, Button } from 'frappe-ui'
import { List, ListRow, ListCell } from 'frappe-ui/list'
const discussions = [
{
name: '1',
title: 'Weekly sync notes',
author: 'Rosa Diaz',
comment: 'Sounds good, let us ship it on Monday',
time: '2 h',
comments: 4,
unread: true,
},
{
name: '2',
title: 'Redesigning the onboarding flow',
author: 'Jake Peralta',
comment: 'I added the new mockups to the page',
time: '5 h',
comments: 12,
unread: false,
},
{
name: '3',
title: 'Q3 hiring plan',
author: 'Amy Santiago',
comment: 'Two backend roles and one designer',
time: '1 d',
comments: 7,
unread: true,
},
{
name: '4',
title: 'Incident review: search downtime',
author: 'Terry Jeffords',
comment: 'Root cause was the index rebuild',
time: '2 d',
comments: 9,
unread: false,
},
{
name: '5',
title: 'Docs sprint retrospective',
author: 'Raymond Holt',
comment: 'Velocity was acceptable.',
time: '4 d',
comments: 3,
unread: false,
},
]
const selectable = ref(false)
const selection = ref<string[]>([])
function toggleSelectMode() {
selectable.value = !selectable.value
selection.value = []
}
</script>
<template>
<div class="w-full">
<div class="mb-2 flex h-7 items-center justify-end gap-3">
<span v-if="selection.length" class="text-sm text-ink-gray-5">
{{ selection.length }} selected
</span>
<Button @click="toggleSelectMode">
{{ selectable ? 'Done' : 'Select' }}
</Button>
</div>
<List
:selectable="selectable"
v-model:selection="selection"
:row-height="60"
>
<ListRow
v-for="discussion in discussions"
:key="discussion.name"
:value="discussion.name"
>
<ListCell>
<Avatar :label="discussion.author" size="2xl" />
</ListCell>
<ListCell>
<div class="min-w-0">
<div
class="truncate text-base text-ink-gray-8"
:class="discussion.unread && 'font-semibold'"
>
{{ discussion.title }}
</div>
<div class="mt-1 truncate text-base text-ink-gray-5">
{{ discussion.author }}: {{ discussion.comment }}
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<div class="flex flex-col items-end gap-1">
<span class="text-sm text-ink-gray-5">{{ discussion.time }}</span>
<Badge>{{ discussion.comments }}</Badge>
</div>
</ListCell>
</ListRow>
</List>
</div>
</template>Row actions
A row that needs a whole-row click and inline action buttons keeps the row static and stacks the two layers itself: a button with absolute inset-0 stretched over the row (rows are position: relative) is the whole-row target, and every control that handles its own pointer events — action buttons, tooltip triggers — gets relative, lifting it above the overlay. Which cells they live in doesn't matter; DOM order does: the overlay first, the layered controls after it. And because those controls are the overlay's siblings, not its children, their clicks never reach it — no stopPropagation. Give the overlay type="button" so it doesn't submit a surrounding form, and the row's own radius so the focus outline follows the row's corners. A static row brings no hover surface or content inset of its own, so add the hover/active classes and list-row-px-3 to keep the interactive look. One gap remains: dividers hide around a hovered row only for interactive rows, so here the hover surface keeps the rule at its top edge — live with it, or pass divider="none". The Files and Tasks recipes show the pattern at scale.
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from 'frappe-ui'
import { List, ListRow, ListCell } from 'frappe-ui/list'
const documents = [
{
id: '1',
title: 'Q3 launch brief',
icon: 'lucide-file-text',
updated: '2 h',
},
{
id: '2',
title: 'Hiring pipeline',
icon: 'lucide-file-spreadsheet',
updated: '5 h',
},
{
id: '3',
title: 'Onboarding flow',
icon: 'lucide-file-image',
updated: '1 d',
},
{
id: '4',
title: 'Retention report',
icon: 'lucide-file-chart-column',
updated: '3 d',
},
{
id: '5',
title: 'Support playbook',
icon: 'lucide-file-text',
updated: '6 d',
},
]
const opened = ref<string>()
const starred = ref<string[]>(['2'])
function toggleStar(id: string) {
starred.value = starred.value.includes(id)
? starred.value.filter((s) => s !== id)
: [...starred.value, id]
}
</script>
<template>
<div class="w-full">
<div class="mb-2 flex h-7 items-center justify-end text-sm text-ink-gray-5">
<span>
{{ starred.length }} starred
{{ opened ? ` · Opened: ${opened}` : ' · Click a row to open it' }}
</span>
</div>
<!-- A row is one interactive element, so these rows stay static: the
content cell stretches an "open" button over the row (rows are
`position: relative`) and the star button layers above it with
`relative` — the overlay's sibling, so no stopPropagation. The
hover/active classes and list-row-px-3 restore the interactive look
and inset a static row doesn't get for free. -->
<List class="list-row-px-3" :row-height="48">
<ListRow
v-for="doc in documents"
:key="doc.id"
class="active:bg-surface-gray-2 sm:rounded-[10px] sm:hover:bg-surface-gray-1"
>
<ListCell>
<span
:class="doc.icon"
class="size-4 text-ink-gray-5"
aria-hidden="true"
/>
</ListCell>
<ListCell>
<!-- `type="button"` so the pattern stays safe inside a form, and
the row's own radius so the global `:focus-visible` outline
follows the row's corners instead of cutting them square. -->
<button
type="button"
class="absolute inset-0 sm:rounded-[10px]"
:aria-label="`Open ${doc.title}`"
@click="opened = doc.title"
/>
<span class="truncate text-base text-ink-gray-8">
{{ doc.title }}
</span>
</ListCell>
<ListCell class="justify-end gap-3">
<span class="text-sm text-ink-gray-5">{{ doc.updated }}</span>
<Button
class="relative"
variant="ghost"
:label="
starred.includes(doc.id)
? `Unstar ${doc.title}`
: `Star ${doc.title}`
"
:aria-pressed="starred.includes(doc.id)"
@click="toggleStar(doc.id)"
>
<!-- Colour lives on an #icon-slot span: Button's ghost classes
already set an ink colour on the button element, and without
tailwind-merge the stylesheet order — not this template —
would decide which class wins there. -->
<template #icon>
<span
class="lucide-star size-4"
:class="
starred.includes(doc.id)
? 'text-ink-gray-9'
: 'text-ink-gray-4'
"
aria-hidden="true"
/>
</template>
</Button>
</ListCell>
</ListRow>
</List>
</div>
</template>Active row
A master–detail list (a mail inbox, a file browser) tracks one open row. Bind v-model:active to a row value and the List owns the rest: it highlights that row and hides the dividers hugging it — above and below — so its rounded surface floats free, like a hovered row but persistent. Clicking a row sets active; unlike selectable, activation is additive, so the row's own @click and route navigation still run. It's single-select and independent of the multi-select checkbox selection — and works in feed or column mode.
<List v-model:active="openId">
<ListRows :items="threads" v-slot="{ value }">
<ListRow :value="value">…</ListRow>
</ListRows>
</List>Column mode
Pass explicit columns and a ListHeader. The List resolves one template and the header and every row read it, so the two grids can never drift.
Use deterministic track sizes. Every row is its own grid, so auto tracks size against that row's content alone and nothing lines up — the intrinsic sizing a real <table> shares across rows has no equivalent here. minmax(0, 1fr) for the content column and fixed widths (or fr ratios) for the rest is the shape that stays aligned.
ListHeaderCell is a plain label with optional #prefix / #suffix adornments. Sortable columns use ListHeaderCellSort instead — a controlled sort button: you hand it the active direction (asc / desc / null) and update your own sort state in its click handler. Your code owns the state, toggle rules, direction glyphs (via the scoped #sort-indicator="{ direction }" slot), and whether ordering happens client-side or through useList orderBy. The cell keeps only the behavioral chrome: a real button, aria-sort, the tooltip, and revealing an inactive column's sort indicator on hover. Both variants render the same data-slot="list-header-cell" geometry, so mixing them in one header is seamless.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar } from 'frappe-ui'
import {
List,
ListRow,
ListCell,
ListHeader,
ListHeaderCellSort,
ListRows,
} from 'frappe-ui/list'
const members = [
{ name: 'Rosa Diaz', email: '[email protected]', role: 'Admin', since: '2021-06' },
{ name: 'Jake Peralta', email: '[email protected]', role: 'Member', since: '2022-01' },
{ name: 'Amy Santiago', email: '[email protected]', role: 'Admin', since: '2020-11' },
{ name: 'Terry Jeffords', email: '[email protected]', role: 'Member', since: '2023-03' },
{ name: 'Raymond Holt', email: '[email protected]', role: 'Guest', since: '2024-08' },
]
const activeMember = ref<string | undefined>('Rosa Diaz')
// Sort state, toggle rules, comparator, and direction icons are all app
// code — the header cells only render the chrome for whatever `direction`
// you hand them.
type Field = 'name' | 'role' | 'since'
function sortIcon(direction: 'asc' | 'desc' | null) {
if (!direction) return 'lucide-arrow-up-down'
return direction === 'asc' ? 'lucide-arrow-up' : 'lucide-arrow-down'
}
const sortField = ref<Field>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
function toggleSort(field: Field, firstDirection: 'asc' | 'desc' = 'asc') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = firstDirection
}
}
function directionFor(field: Field) {
return sortField.value === field ? sortDirection.value : null
}
const sortedMembers = computed(() => {
const factor = sortDirection.value === 'desc' ? -1 : 1
return [...members].sort(
(a, b) => factor * a[sortField.value].localeCompare(b[sortField.value]),
)
})
</script>
<template>
<!-- v-model:active makes the rows clickable, and clickable rows carry a
0.75rem hover-surface inset. `list-row-px-3` hands the header the same
inset, so its labels stay aligned with the cell text below them — the
canonical pairing for a column-mode list with interactive rows. -->
<List
v-model:active="activeMember"
class="w-full list-row-px-3"
:columns="['minmax(0,1fr)', '7rem', '8rem']"
:row-height="56"
>
<ListHeader>
<ListHeaderCellSort :direction="directionFor('name')" @click="toggleSort('name')">
Member
<template #sort-indicator="{ direction }">
<span class="block size-3.5" :class="sortIcon(direction)" />
</template>
</ListHeaderCellSort>
<ListHeaderCellSort :direction="directionFor('role')" @click="toggleSort('role')">
Role
<template #sort-indicator="{ direction }">
<span class="block size-3.5" :class="sortIcon(direction)" />
</template>
</ListHeaderCellSort>
<ListHeaderCellSort
:direction="directionFor('since')"
class="justify-end"
@click="toggleSort('since', 'desc')"
>
Member since
<template #sort-indicator="{ direction }">
<span class="block size-3.5" :class="sortIcon(direction)" />
</template>
</ListHeaderCellSort>
</ListHeader>
<ListRows :items="sortedMembers" v-slot="{ item: member, value }">
<ListRow :value="value">
<ListCell>
<Avatar :label="member.name" size="xl" />
<div class="ml-3 min-w-0">
<div class="truncate text-base text-ink-gray-8">{{ member.name }}</div>
<div class="mt-0.5 truncate text-sm text-ink-gray-5">{{ member.email }}</div>
</div>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-7">{{ member.role }}</span>
</ListCell>
<ListCell class="justify-end">
<span class="text-base text-ink-gray-6">{{ member.since }}</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</template>Responsive columns
A table that fits a desktop rarely fits a phone. Pass columns as an object keyed by breakpoint and the List switches templates with the viewport:
<List
:columns="{
base: ['minmax(0,1fr)', '80px', '64px'],
md: ['minmax(0,1fr)', '140px', '100px'],
lg: ['minmax(0,2fr)', '180px', '120px'],
}"
>base is required and applies from zero width. Every other key names a breakpoint from your own Tailwind screens and applies from that width upward, until the next supplied breakpoint — sm and xl are missing above, so sm keeps the base template and xl keeps the lg one.
Each breakpoint replaces the whole template. Nothing is merged track by track, so a breakpoint is free to change the track count as well as the widths.
The switch happens in CSS, against your app's breakpoint values — a md you redefined moves the list's tracks and your md:hidden utilities together. That also means the server-rendered markup is already correct: there is no viewport measurement, no resize listener and no first-paint flash.
A key names a screen, so every shape a Tailwind screen can take works, not only a plain width: a { min, max } screen gives a tier that ends where the screen ends, a { max } screen one that applies below a width, and a { raw } screen one that applies wherever its query matches. In each case the tier is live in exactly the same places as that screen's own variants. Where two screens match at once, the tier that wins is the one whose utilities win.
A key that is not one of your screens is ignored.{ base: […], medium: […] } renders base at every width, because medium names no breakpoint and nothing switches to it. Breakpoint names come from your Tailwind config, so the type cannot reject the key — the index signature on ListColumnsByBreakpoint has to stay open for apps with custom screens. A development build warns instead, naming the key and listing the screens it could have been:
[frappe-ui] List: `columns` key `medium` is not one of this app's Tailwind
screens (base, sm, md, lg, xl), so its template is ignored and the list keeps
the one below it.The warning is stripped from production builds. It needs frappe-ui's Tailwind preset, which is what tells the List which screens your app defines; without the preset every key above base is ignored anyway, and the warning says so.
Changing the track count never hides a cell. Say that part explicitly, with matching classes on the header and the rows:
<List
:columns="{ base: ['minmax(0,1fr)', '64px'], md: ['minmax(0,1fr)', '140px', '100px'] }"
>
<ListHeader>
<ListHeaderCell>Member</ListHeaderCell>
<ListHeaderCell class="max-md:hidden">Role</ListHeaderCell>
<ListHeaderCell>Since</ListHeaderCell>
</ListHeader>
…
</List>Each List owns its own columns. A list nested inside another list keeps its own columns prop, or the default feed template when it has none — an outer template never reaches it.
Row height stays a plain prop. A per-breakpoint height would silently desync virtual windowing, so rowHeight is one number at every width; for a non-virtual list, set responsive heights with height classes on the rows.
<script setup lang="ts">
import { Avatar } from 'frappe-ui'
import {
List,
ListRow,
ListCell,
ListHeader,
ListHeaderCell,
ListRows,
} from 'frappe-ui/list'
const members = [
{ name: 'Rosa Diaz', email: '[email protected]', role: 'Admin', since: '2021-06' },
{ name: 'Jake Peralta', email: '[email protected]', role: 'Member', since: '2022-01' },
{ name: 'Amy Santiago', email: '[email protected]', role: 'Admin', since: '2020-11' },
{ name: 'Terry Jeffords', email: '[email protected]', role: 'Member', since: '2023-03' },
]
</script>
<template>
<!-- Narrow: name and date only. From md: the role column joins, and the
fixed tracks widen again at lg. Dropping a track never hides its cells
on its own, so the Role header and cell carry `max-md:hidden` to match. -->
<List
class="w-full list-row-px-3"
:columns="{
base: ['minmax(0,1fr)', '6.5rem'],
md: ['minmax(0,1fr)', '7rem', '6rem'],
lg: ['minmax(0,2fr)', '9rem', '8rem'],
}"
:row-height="56"
>
<ListHeader>
<ListHeaderCell>Member</ListHeaderCell>
<ListHeaderCell class="max-md:hidden">Role</ListHeaderCell>
<ListHeaderCell class="justify-end">Member since</ListHeaderCell>
</ListHeader>
<ListRows :items="members" v-slot="{ item: member, value }">
<ListRow :value="value">
<ListCell>
<Avatar :label="member.name" size="xl" />
<div class="ml-3 min-w-0">
<div class="truncate text-base text-ink-gray-8">{{ member.name }}</div>
<div class="mt-0.5 truncate text-sm text-ink-gray-5">{{ member.email }}</div>
</div>
</ListCell>
<ListCell class="max-md:hidden">
<span class="text-base text-ink-gray-7">{{ member.role }}</span>
</ListCell>
<ListCell class="justify-end">
<span class="text-base text-ink-gray-6">{{ member.since }}</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</template>Virtual rows
ListRows iterates items through its scoped slot; with virtual, only rows near the viewport mount. The scoped slot receives { item, index, value, selected, active }, where selected and active are independent, where value is the string row identity used by select-all and active-row state. The scroll container is the nearest scrollable ancestor — the list windows against an app-owned scroll area (a settings body, the page) and keeps its scrollbar. virtual is a boolean and overscan controls the extra rows on each side; height always comes from the parent List's rowHeight. Virtualization is owned by ListRows.
Styling hooks
--list-gap (default 0.5rem) and --list-row-padding-x are the list's public CSS hooks. Set them with plain (responsive) classes on the List — or on any ancestor, to theme every list in a subtree. Their defaults live in var() fallbacks, so a consumer value always wins.
Column templates are deliberately not a hook. They come from the columns prop alone, which is what lets every List — nested ones included — own its own grid.
--list-row-padding-x is the inline content inset, and its default is asymmetric on purpose: interactive rows get 0.75rem so the rounded hover surface clears their content, while static rows, the header and group headers sit flush at 0 — a header can't tell whether its sibling rows are interactive. Setting the hook gives every row and the header the same value. A column-mode list with clickable rows and a header should always set it (list-row-px-3) so the header labels stay aligned with the cell text below them. The checkbox column follows the same rule: in a selectable list with a header, the hook is also what lines the select-all checkbox up with the row checkboxes.
For both hooks the frappe-ui Tailwind preset ships spacing-scale utilities — list-gap-* and list-row-px-* — so the usual authoring form is max-sm:list-gap-3 sm:list-gap-4 rather than raw [--list-gap:0.75rem] properties. Both forms hit the same CSS vars.
The prop/hook split follows one rule: knobs that drive behavior are props (columns also flips the divider default, rowHeight also feeds virtual windowing), knobs that are pure geometry are CSS hooks. Vars with a --_list prefix are internal, not API — they can change in any release, and they reset at every List, so a nested list never inherits an outer list's props. The resolved column template is one of them.
Cells (and plain header cells) are flex containers with items-center — align content with justify utilities (class="justify-end" for numeric columns), responsively if needed. For sortable numeric headers, use <ListHeaderCellSort align="end"> so the sort glyph moves to the leading side and the label stays flush with the column edge.
Slots for CSS targeting: data-slot="list | list-header | list-header-cell | list-header-checkbox | list-row | list-cell | list-row-checkbox | list-group | list-group-header | list-divider". Slots not listed here are internal and may change. State: data-state="active|inactive" (+ aria-current on the v-model:active row), boolean data-selected for checkbox selection, and boolean data-interactive on rows. Active and selected are independent. Header cells use data-sort when sorted.
data-slot="list-group-header" names the structural header element and stays unchanged when the ListGroup content slot is renamed to #label.
Accessibility follows header presence: role="list" / "listitem" without a ListHeader, table / row / columnheader / cell (plus aria-sort) with one.
API Reference
List
Show types
import type { RouteLocationRaw } from 'vue-router'
export type ListDivider = 'inset' | 'full' | 'none'
export type ListSortDirection = 'asc' | 'desc'
/**
* One complete track template per breakpoint. `base` is required and applies
* from zero width up; every other key names a breakpoint from the app's
* Tailwind `screens` and applies from that viewport width upward, until the
* next supplied breakpoint. `sm` / `md` / `lg` / `xl` are the preset's own
* names — an app with custom screens uses its own. A screen that is not a
* plain width (a `{ min, max }` band, a `{ max }` ceiling, a `{ raw }` query)
* gives a tier that is live wherever that screen's own variants are live.
*
* A key that is not one of the app's screens is ignored — its template never
* applies. The index signature has to stay open because the names belong to
* the app, so the type cannot reject it; a dev-mode warning does.
*
* Each value replaces the whole template. Arrays are never merged track by
* track, so a breakpoint may change the track count as well as the widths.
*/
export interface ListColumnsByBreakpoint {
/** Applies from zero width, up to the smallest supplied breakpoint. */
base: string[]
sm?: string[]
md?: string[]
lg?: string[]
xl?: string[]
[breakpoint: string]: string[] | undefined
}
/**
* `columns` in either form: one template for every width, or one template per
* breakpoint.
*/
export type ListColumns = string[] | ListColumnsByBreakpoint
export interface ListProps {
/**
* Grid track sizes shared by the header and every row. Defaults to the feed
* template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content,
* trailing). Table-style lists must pass deterministic track sizes — `auto`
* tracks size independently per row, so independent row grids can't agree.
*
* Pass an array for one template at every width, or an object keyed by
* breakpoint for a template that changes with the viewport:
* `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`.
* `base` is required, each breakpoint replaces the whole template, and an
* omitted breakpoint keeps the one below it. Breakpoints are the consuming
* app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden`
* on a cell switch at the same width. A key that is not one of those screens
* is ignored: its template never applies, and a dev-mode warning names it.
* Changing the track count never hides a cell: pair it with matching
* visibility classes on the header and the rows.
*/
columns?: ListColumns
/**
* Divider treatment between rows: `inset` starts at the content column
* (the text edge), `full` spans all columns. Defaults to `inset` with the
* default feed template, `full` when `columns` is set.
*/
divider?: ListDivider
/**
* Reveals the animated checkbox column and switches row click from
* navigate to toggle. Selected values surface via `v-model:selection`.
*/
selectable?: boolean
// Two more models live on List but aren't plain props (so they're not in
// this interface): `v-model:selection` (string[], the checkbox set) and
// `v-model:active` (string, the single open/highlighted row — the List
// styles it and hides the dividers hugging it). See List.vue.
/**
* Fixed row height in px. Required for virtualization; without it rows size
* to their content. Responsive heights are non-virtual — set them with
* height classes on the rows instead.
*/
rowHeight?: number
}
export interface ListRowProps {
/**
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
* listener renders as a button; otherwise a plain div.
*/
route?: RouteLocationRaw
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
href?: string
/**
* Row key — the `selection` key when `selectable` and the `v-model:active`
* key. Required whenever the list uses either.
*/
value?: string
/** Fired when the row is activated, unless selection mode claims the click. */
onClick?: (event: MouseEvent) => void
}
export interface ListHeaderCellSortProps {
/**
* Active sort direction for this column, `null`/omitted when inactive.
* The cell is controlled — sort state and toggle rules are app-owned:
* update whatever drives your ordering in the `click` handler.
*/
direction?: ListSortDirection | null
/**
* Horizontal alignment of the header content. `'end'` right-aligns the cell
* (for numeric/right-aligned columns) *and* moves the sort glyph to the
* leading side, so the label stays flush with the column's right edge and
* lines up with the values below. Defaults to `'start'`.
*/
align?: 'start' | 'end'
}Grid track sizes shared by the header and every row. Defaults to the feed template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content, trailing). Table-style lists must pass deterministic track sizes — `auto` tracks size independently per row, so independent row grids can't agree. Pass an array for one template at every width, or an object keyed by breakpoint for a template that changes with the viewport: `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`. `base` is required, each breakpoint replaces the whole template, and an omitted breakpoint keeps the one below it. Breakpoints are the consuming app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden` on a cell switch at the same width. A key that is not one of those screens is ignored: its template never applies, and a dev-mode warning names it. Changing the track count never hides a cell: pair it with matching visibility classes on the header and the rows.
Divider treatment between rows: `inset` starts at the content column (the text edge), `full` spans all columns. Defaults to `inset` with the default feed template, `full` when `columns` is set.
Reveals the animated checkbox column and switches row click from navigate to toggle. Selected values surface via `v-model:selection`.
Fixed row height in px. Required for virtualization; without it rows size to their content. Responsive heights are non-virtual — set them with height classes on the rows instead.
The checkbox-selected row values, when `selectable` reveals the checkbox column. Two-way — toggling a row's checkbox updates this set.
The single open/highlighted row, for a master–detail layout. Binding this model is what opts a list into active-row tracking — an unbound list shows no highlight. Independent of `selection`.
| Slot | Payload |
|---|---|
default | — The list's rows — `<ListRow>` / `<ListRows>`, optionally under `<ListHeader>` / `<ListGroup>`. |
The list's rows — `<ListRow>` / `<ListRows>`, optionally under `<ListHeader>` / `<ListGroup>`.
| Event | Payload |
|---|---|
update:selection | [value: string[]] Fired when the selection changes. |
update:active | [value: string | undefined] Fired when the active changes. |
Fired when the selection changes.
Fired when the active changes.
ListRow
Show types
import type { RouteLocationRaw } from 'vue-router'
export type ListDivider = 'inset' | 'full' | 'none'
export type ListSortDirection = 'asc' | 'desc'
/**
* One complete track template per breakpoint. `base` is required and applies
* from zero width up; every other key names a breakpoint from the app's
* Tailwind `screens` and applies from that viewport width upward, until the
* next supplied breakpoint. `sm` / `md` / `lg` / `xl` are the preset's own
* names — an app with custom screens uses its own. A screen that is not a
* plain width (a `{ min, max }` band, a `{ max }` ceiling, a `{ raw }` query)
* gives a tier that is live wherever that screen's own variants are live.
*
* A key that is not one of the app's screens is ignored — its template never
* applies. The index signature has to stay open because the names belong to
* the app, so the type cannot reject it; a dev-mode warning does.
*
* Each value replaces the whole template. Arrays are never merged track by
* track, so a breakpoint may change the track count as well as the widths.
*/
export interface ListColumnsByBreakpoint {
/** Applies from zero width, up to the smallest supplied breakpoint. */
base: string[]
sm?: string[]
md?: string[]
lg?: string[]
xl?: string[]
[breakpoint: string]: string[] | undefined
}
/**
* `columns` in either form: one template for every width, or one template per
* breakpoint.
*/
export type ListColumns = string[] | ListColumnsByBreakpoint
export interface ListProps {
/**
* Grid track sizes shared by the header and every row. Defaults to the feed
* template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content,
* trailing). Table-style lists must pass deterministic track sizes — `auto`
* tracks size independently per row, so independent row grids can't agree.
*
* Pass an array for one template at every width, or an object keyed by
* breakpoint for a template that changes with the viewport:
* `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`.
* `base` is required, each breakpoint replaces the whole template, and an
* omitted breakpoint keeps the one below it. Breakpoints are the consuming
* app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden`
* on a cell switch at the same width. A key that is not one of those screens
* is ignored: its template never applies, and a dev-mode warning names it.
* Changing the track count never hides a cell: pair it with matching
* visibility classes on the header and the rows.
*/
columns?: ListColumns
/**
* Divider treatment between rows: `inset` starts at the content column
* (the text edge), `full` spans all columns. Defaults to `inset` with the
* default feed template, `full` when `columns` is set.
*/
divider?: ListDivider
/**
* Reveals the animated checkbox column and switches row click from
* navigate to toggle. Selected values surface via `v-model:selection`.
*/
selectable?: boolean
// Two more models live on List but aren't plain props (so they're not in
// this interface): `v-model:selection` (string[], the checkbox set) and
// `v-model:active` (string, the single open/highlighted row — the List
// styles it and hides the dividers hugging it). See List.vue.
/**
* Fixed row height in px. Required for virtualization; without it rows size
* to their content. Responsive heights are non-virtual — set them with
* height classes on the rows instead.
*/
rowHeight?: number
}
export interface ListRowProps {
/**
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
* listener renders as a button; otherwise a plain div.
*/
route?: RouteLocationRaw
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
href?: string
/**
* Row key — the `selection` key when `selectable` and the `v-model:active`
* key. Required whenever the list uses either.
*/
value?: string
/** Fired when the row is activated, unless selection mode claims the click. */
onClick?: (event: MouseEvent) => void
}
export interface ListHeaderCellSortProps {
/**
* Active sort direction for this column, `null`/omitted when inactive.
* The cell is controlled — sort state and toggle rules are app-owned:
* update whatever drives your ordering in the `click` handler.
*/
direction?: ListSortDirection | null
/**
* Horizontal alignment of the header content. `'end'` right-aligns the cell
* (for numeric/right-aligned columns) *and* moves the sort glyph to the
* leading side, so the label stays flush with the column's right edge and
* lines up with the values below. Defaults to `'start'`.
*/
align?: 'start' | 'end'
}Renders the row as a RouterLink. Without `route` or `href`, a row with a click listener renders as a button; otherwise a plain div.
External URL. Used when `route` is absent; renders a native same-tab anchor.
Row key — the `selection` key when `selectable` and the `v-model:active` key. Required whenever the list uses either.
Fired when the row is activated, unless selection mode claims the click.
| Slot | Payload |
|---|---|
default | — The row's cells — `<ListCell>` elements, or feed content directly. |
The row's cells — `<ListCell>` elements, or feed content directly.
ListCell
| Slot | Payload |
|---|---|
default | — The cell's content. |
The cell's content.
ListHeader
| Slot | Payload |
|---|---|
default | — The header's columns — `<ListHeaderCell>` / `<ListHeaderCellSort>` elements. |
The header's columns — `<ListHeaderCell>` / `<ListHeaderCellSort>` elements.
ListHeaderCell
| Slot | Payload |
|---|---|
default | — Column label. |
prefix | — Leading adornment, rendered before the label. |
suffix | — Trailing adornment, rendered after the label. |
Column label.
Leading adornment, rendered before the label.
Trailing adornment, rendered after the label.
ListHeaderCellSort
Show types
import type { RouteLocationRaw } from 'vue-router'
export type ListDivider = 'inset' | 'full' | 'none'
export type ListSortDirection = 'asc' | 'desc'
/**
* One complete track template per breakpoint. `base` is required and applies
* from zero width up; every other key names a breakpoint from the app's
* Tailwind `screens` and applies from that viewport width upward, until the
* next supplied breakpoint. `sm` / `md` / `lg` / `xl` are the preset's own
* names — an app with custom screens uses its own. A screen that is not a
* plain width (a `{ min, max }` band, a `{ max }` ceiling, a `{ raw }` query)
* gives a tier that is live wherever that screen's own variants are live.
*
* A key that is not one of the app's screens is ignored — its template never
* applies. The index signature has to stay open because the names belong to
* the app, so the type cannot reject it; a dev-mode warning does.
*
* Each value replaces the whole template. Arrays are never merged track by
* track, so a breakpoint may change the track count as well as the widths.
*/
export interface ListColumnsByBreakpoint {
/** Applies from zero width, up to the smallest supplied breakpoint. */
base: string[]
sm?: string[]
md?: string[]
lg?: string[]
xl?: string[]
[breakpoint: string]: string[] | undefined
}
/**
* `columns` in either form: one template for every width, or one template per
* breakpoint.
*/
export type ListColumns = string[] | ListColumnsByBreakpoint
export interface ListProps {
/**
* Grid track sizes shared by the header and every row. Defaults to the feed
* template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content,
* trailing). Table-style lists must pass deterministic track sizes — `auto`
* tracks size independently per row, so independent row grids can't agree.
*
* Pass an array for one template at every width, or an object keyed by
* breakpoint for a template that changes with the viewport:
* `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`.
* `base` is required, each breakpoint replaces the whole template, and an
* omitted breakpoint keeps the one below it. Breakpoints are the consuming
* app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden`
* on a cell switch at the same width. A key that is not one of those screens
* is ignored: its template never applies, and a dev-mode warning names it.
* Changing the track count never hides a cell: pair it with matching
* visibility classes on the header and the rows.
*/
columns?: ListColumns
/**
* Divider treatment between rows: `inset` starts at the content column
* (the text edge), `full` spans all columns. Defaults to `inset` with the
* default feed template, `full` when `columns` is set.
*/
divider?: ListDivider
/**
* Reveals the animated checkbox column and switches row click from
* navigate to toggle. Selected values surface via `v-model:selection`.
*/
selectable?: boolean
// Two more models live on List but aren't plain props (so they're not in
// this interface): `v-model:selection` (string[], the checkbox set) and
// `v-model:active` (string, the single open/highlighted row — the List
// styles it and hides the dividers hugging it). See List.vue.
/**
* Fixed row height in px. Required for virtualization; without it rows size
* to their content. Responsive heights are non-virtual — set them with
* height classes on the rows instead.
*/
rowHeight?: number
}
export interface ListRowProps {
/**
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
* listener renders as a button; otherwise a plain div.
*/
route?: RouteLocationRaw
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
href?: string
/**
* Row key — the `selection` key when `selectable` and the `v-model:active`
* key. Required whenever the list uses either.
*/
value?: string
/** Fired when the row is activated, unless selection mode claims the click. */
onClick?: (event: MouseEvent) => void
}
export interface ListHeaderCellSortProps {
/**
* Active sort direction for this column, `null`/omitted when inactive.
* The cell is controlled — sort state and toggle rules are app-owned:
* update whatever drives your ordering in the `click` handler.
*/
direction?: ListSortDirection | null
/**
* Horizontal alignment of the header content. `'end'` right-aligns the cell
* (for numeric/right-aligned columns) *and* moves the sort glyph to the
* leading side, so the label stays flush with the column's right edge and
* lines up with the values below. Defaults to `'start'`.
*/
align?: 'start' | 'end'
}Active sort direction for this column, `null`/omitted when inactive. The cell is controlled — sort state and toggle rules are app-owned: update whatever drives your ordering in the `click` handler.
Horizontal alignment of the header content. `'end'` right-aligns the cell (for numeric/right-aligned columns) *and* moves the sort glyph to the leading side, so the label stays flush with the column's right edge and lines up with the values below. Defaults to `'start'`.
| Slot | Payload |
|---|---|
default | — Column label. |
prefix | { direction: "asc" | "desc" | null; } Leading adornment, rendered before the label. |
sort-indicator | { direction: "asc" | "desc" | null; } Sort glyph. Optional — the cell renders a built-in arrow from `direction` by default. Provide this to override (e.g. a custom lucide span). The cell owns the reveal: an inactive column's glyph shows on hover. |
Column label.
Leading adornment, rendered before the label.
Sort glyph. Optional — the cell renders a built-in arrow from `direction` by default. Provide this to override (e.g. a custom lucide span). The cell owns the reveal: an inactive column's glyph shows on hover.
| Event | Payload |
|---|---|
click | [event: MouseEvent] Fired on sort button click — update your sort state here. |
Fired on sort button click — update your sort state here.
ListRows
Show types
import type { RouteLocationRaw } from 'vue-router'
export type ListDivider = 'inset' | 'full' | 'none'
export type ListSortDirection = 'asc' | 'desc'
/**
* One complete track template per breakpoint. `base` is required and applies
* from zero width up; every other key names a breakpoint from the app's
* Tailwind `screens` and applies from that viewport width upward, until the
* next supplied breakpoint. `sm` / `md` / `lg` / `xl` are the preset's own
* names — an app with custom screens uses its own. A screen that is not a
* plain width (a `{ min, max }` band, a `{ max }` ceiling, a `{ raw }` query)
* gives a tier that is live wherever that screen's own variants are live.
*
* A key that is not one of the app's screens is ignored — its template never
* applies. The index signature has to stay open because the names belong to
* the app, so the type cannot reject it; a dev-mode warning does.
*
* Each value replaces the whole template. Arrays are never merged track by
* track, so a breakpoint may change the track count as well as the widths.
*/
export interface ListColumnsByBreakpoint {
/** Applies from zero width, up to the smallest supplied breakpoint. */
base: string[]
sm?: string[]
md?: string[]
lg?: string[]
xl?: string[]
[breakpoint: string]: string[] | undefined
}
/**
* `columns` in either form: one template for every width, or one template per
* breakpoint.
*/
export type ListColumns = string[] | ListColumnsByBreakpoint
export interface ListProps {
/**
* Grid track sizes shared by the header and every row. Defaults to the feed
* template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content,
* trailing). Table-style lists must pass deterministic track sizes — `auto`
* tracks size independently per row, so independent row grids can't agree.
*
* Pass an array for one template at every width, or an object keyed by
* breakpoint for a template that changes with the viewport:
* `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`.
* `base` is required, each breakpoint replaces the whole template, and an
* omitted breakpoint keeps the one below it. Breakpoints are the consuming
* app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden`
* on a cell switch at the same width. A key that is not one of those screens
* is ignored: its template never applies, and a dev-mode warning names it.
* Changing the track count never hides a cell: pair it with matching
* visibility classes on the header and the rows.
*/
columns?: ListColumns
/**
* Divider treatment between rows: `inset` starts at the content column
* (the text edge), `full` spans all columns. Defaults to `inset` with the
* default feed template, `full` when `columns` is set.
*/
divider?: ListDivider
/**
* Reveals the animated checkbox column and switches row click from
* navigate to toggle. Selected values surface via `v-model:selection`.
*/
selectable?: boolean
// Two more models live on List but aren't plain props (so they're not in
// this interface): `v-model:selection` (string[], the checkbox set) and
// `v-model:active` (string, the single open/highlighted row — the List
// styles it and hides the dividers hugging it). See List.vue.
/**
* Fixed row height in px. Required for virtualization; without it rows size
* to their content. Responsive heights are non-virtual — set them with
* height classes on the rows instead.
*/
rowHeight?: number
}
export interface ListRowProps {
/**
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
* listener renders as a button; otherwise a plain div.
*/
route?: RouteLocationRaw
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
href?: string
/**
* Row key — the `selection` key when `selectable` and the `v-model:active`
* key. Required whenever the list uses either.
*/
value?: string
/** Fired when the row is activated, unless selection mode claims the click. */
onClick?: (event: MouseEvent) => void
}
export interface ListHeaderCellSortProps {
/**
* Active sort direction for this column, `null`/omitted when inactive.
* The cell is controlled — sort state and toggle rules are app-owned:
* update whatever drives your ordering in the `click` handler.
*/
direction?: ListSortDirection | null
/**
* Horizontal alignment of the header content. `'end'` right-aligns the cell
* (for numeric/right-aligned columns) *and* moves the sort glyph to the
* leading side, so the label stays flush with the column's right edge and
* lines up with the values below. Defaults to `'start'`.
*/
align?: 'start' | 'end'
}Items to iterate — one default-slot render per item.
How to derive a row's identity. A string reads that property off the item; a function computes it. Drives the render `:key`, the header select-all universe, and the scoped `value` slot prop. Defaults to the item's `name`, then `id`, then the index.
Window the rows so only rows near the viewport mount. Height comes from the parent List's `rowHeight`; the scroll container is the nearest scrollable ancestor.
Rows rendered beyond the visible window on each side. Default: `6`.
| Slot | Payload |
|---|---|
default | { item: T; index: number; value: string; selected: boolean; active: boolean; } One render per item. `active` and `selected` are independent row states. |
One render per item. `active` and `selected` are independent row states.
ListGroup
Show types
import type { RouteLocationRaw } from 'vue-router'
export type ListDivider = 'inset' | 'full' | 'none'
export type ListSortDirection = 'asc' | 'desc'
/**
* One complete track template per breakpoint. `base` is required and applies
* from zero width up; every other key names a breakpoint from the app's
* Tailwind `screens` and applies from that viewport width upward, until the
* next supplied breakpoint. `sm` / `md` / `lg` / `xl` are the preset's own
* names — an app with custom screens uses its own. A screen that is not a
* plain width (a `{ min, max }` band, a `{ max }` ceiling, a `{ raw }` query)
* gives a tier that is live wherever that screen's own variants are live.
*
* A key that is not one of the app's screens is ignored — its template never
* applies. The index signature has to stay open because the names belong to
* the app, so the type cannot reject it; a dev-mode warning does.
*
* Each value replaces the whole template. Arrays are never merged track by
* track, so a breakpoint may change the track count as well as the widths.
*/
export interface ListColumnsByBreakpoint {
/** Applies from zero width, up to the smallest supplied breakpoint. */
base: string[]
sm?: string[]
md?: string[]
lg?: string[]
xl?: string[]
[breakpoint: string]: string[] | undefined
}
/**
* `columns` in either form: one template for every width, or one template per
* breakpoint.
*/
export type ListColumns = string[] | ListColumnsByBreakpoint
export interface ListProps {
/**
* Grid track sizes shared by the header and every row. Defaults to the feed
* template `['auto', 'minmax(0,1fr)', 'auto']` (leading media, content,
* trailing). Table-style lists must pass deterministic track sizes — `auto`
* tracks size independently per row, so independent row grids can't agree.
*
* Pass an array for one template at every width, or an object keyed by
* breakpoint for a template that changes with the viewport:
* `{ base: ['minmax(0,1fr)', '80px'], md: ['minmax(0,2fr)', '140px', '100px'] }`.
* `base` is required, each breakpoint replaces the whole template, and an
* omitted breakpoint keeps the one below it. Breakpoints are the consuming
* app's own Tailwind `screens`, resolved in CSS — so `md` here and `md:hidden`
* on a cell switch at the same width. A key that is not one of those screens
* is ignored: its template never applies, and a dev-mode warning names it.
* Changing the track count never hides a cell: pair it with matching
* visibility classes on the header and the rows.
*/
columns?: ListColumns
/**
* Divider treatment between rows: `inset` starts at the content column
* (the text edge), `full` spans all columns. Defaults to `inset` with the
* default feed template, `full` when `columns` is set.
*/
divider?: ListDivider
/**
* Reveals the animated checkbox column and switches row click from
* navigate to toggle. Selected values surface via `v-model:selection`.
*/
selectable?: boolean
// Two more models live on List but aren't plain props (so they're not in
// this interface): `v-model:selection` (string[], the checkbox set) and
// `v-model:active` (string, the single open/highlighted row — the List
// styles it and hides the dividers hugging it). See List.vue.
/**
* Fixed row height in px. Required for virtualization; without it rows size
* to their content. Responsive heights are non-virtual — set them with
* height classes on the rows instead.
*/
rowHeight?: number
}
export interface ListRowProps {
/**
* Renders the row as a RouterLink. Without `route` or `href`, a row with a click
* listener renders as a button; otherwise a plain div.
*/
route?: RouteLocationRaw
/** External URL. Used when `route` is absent; renders a native same-tab anchor. */
href?: string
/**
* Row key — the `selection` key when `selectable` and the `v-model:active`
* key. Required whenever the list uses either.
*/
value?: string
/** Fired when the row is activated, unless selection mode claims the click. */
onClick?: (event: MouseEvent) => void
}
export interface ListHeaderCellSortProps {
/**
* Active sort direction for this column, `null`/omitted when inactive.
* The cell is controlled — sort state and toggle rules are app-owned:
* update whatever drives your ordering in the `click` handler.
*/
direction?: ListSortDirection | null
/**
* Horizontal alignment of the header content. `'end'` right-aligns the cell
* (for numeric/right-aligned columns) *and* moves the sort glyph to the
* leading side, so the label stays flush with the column's right edge and
* lines up with the values below. Defaults to `'start'`.
*/
align?: 'start' | 'end'
}Section label shown in the group header. Overridden by the #label slot.
Pin the group header to the top of the scroll container while its rows scroll under it. Off by default.
| Slot | Payload |
|---|---|
default | — The group's rows — `<ListRow>` elements. |
label | — Replaces the group label. |
The group's rows — `<ListRow>` elements.
Replaces the group label.