List ​

A list or table built from parts, imported from frappe-ui/list. It is not the config-driven ListView in frappe-ui/experimental, which is a separate component.

R
Weekly sync notes
Rosa Diaz: Sounds good, let us ship it on Monday
2 h
4
J
Redesigning the onboarding flow
Jake Peralta: I added the new mockups to the page
5 h
12
A
Q3 hiring plan
Amy Santiago: Two backend roles and one designer
1 d
7
T
Incident review: search downtime
Terry Jeffords: Root cause was the index rebuild
2 d
9
R
Docs sprint retrospective
Raymond Holt: Velocity was acceptable.
4 d
3
vue
<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>

Anatomy ​

List holds the column template, the selection and the active row. ListHeader is an optional header row. ListRows renders one row per item, and each ListRow holds one ListCell per column. ListGroup puts a label above a set of rows. The list draws the columns, dividers, hover surfaces, checkboxes and sort buttons. You write everything inside the cells: text, avatars, badges and unread emphasis.

vue
<List v-model:selection="selection" :columns="['minmax(0,1fr)', '8rem']" selectable>
  <ListHeader>
    <ListHeaderCellSort :direction="direction" @click="toggleSort">
      Name
    </ListHeaderCellSort>
    <ListHeaderCell>Role</ListHeaderCell>
  </ListHeader>

  <ListGroup label="Admins">
    <ListRows :items="admins" v-slot="{ item, value }">
      <ListRow :value="value">
        <ListCell>{{ item.name }}</ListCell>
        <ListCell>{{ item.role }}</ListCell>
      </ListRow>
    </ListRows>
  </ListGroup>
</List>

Examples ​

Member table ​

Explicit columns and a ListHeader turn the list into a table. ListHeaderCellSort makes a column sortable, and v-model:active highlights the row that was clicked last.

vue
<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>

Documents with a star button ​

Each row opens its document on click and has a star button. A row cannot be a link or button and also hold buttons, so the rows stay static and a stretched button covers each one.

1 starred · Click a row to open it
Q3 launch brief
2 h
Hiring pipeline
5 h
Onboarding flow
1 d
Retention report
3 d
Support playbook
6 d
vue
<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>

Table on a phone ​

columns keyed by breakpoint gives the table two columns on a phone and three from md up. Resize the window to see it switch.

Member
Role
Member since
Admin
2021-06
J
Jake Peralta
Member
2022-01
A
Amy Santiago
Admin
2020-11
T
Terry Jeffords
Member
2023-03
vue
<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>

A thousand tasks ​

virtual on ListRows mounts only the rows near the visible part of the scroll area.

Behavior ​

Feed and column layouts ​

Every list is a grid, and the header and every row read one column template. Without columns, the list uses the feed template auto, minmax(0,1fr), auto: leading media, the content, and a right-aligned trailing cell.

Pass columns for a table. Use fixed track sizes. Each row is its own grid, so an auto track sizes to that row's content alone, and the columns do not line up across rows. Use minmax(0, 1fr) for the content column and fixed widths or fr ratios for the rest.

Cells are flex containers with items-center. Align their content with justify classes, such as class="justify-end" for a number column.

What a row renders ​

A row with route renders a router link, a row with href a plain link that opens in the same tab, and a row with a click listener a button. Any other row is a plain div.

A link or button cannot hold other buttons, so a row is either clickable or holds inline buttons, not both.

Clickable rows with buttons ​

To have both, keep the row static and put two layers in it. The first is a button with absolute inset-0, which stretches over the row because rows are position: relative. It is the whole-row target. Every control after it that handles its own clicks, such as action buttons and tooltip triggers, gets the relative class, which lifts it above the stretched button.

The cells these controls sit in do not matter, but the order does: the stretched button first, the other controls after it. The controls are its siblings, not its children, so their clicks never reach it and you do not need stopPropagation. Give the stretched button type="button" so it does not submit a surrounding form, and the row's corner radius so its focus outline follows the row's corners.

A static row has no hover surface or content inset of its own, so add hover and active classes as the documents example does. Dividers hide around a hovered row only when the row itself is clickable, so here the divider stays at the top of the hover surface. Leave it, or pass divider="none".

Dividers ​

divider is inset, full or none. It defaults to inset in the feed layout and full when columns is set. An inset divider starts at the content column, the text edge. There is no divider above the first row, and the dividers around a hovered clickable row hide so its rounded surface stands apart.

Row padding and gap ​

Two CSS variables set the list's spacing:

  • --list-gap: the space between columns. The default is 0.5rem.
  • --list-row-padding-x: the space between a row's edge and its content.

The frappe-ui Tailwind preset has a utility for each: list-gap-* and list-row-px-*, on the spacing scale. Put them on the List, or on any parent to set every list inside it. Responsive prefixes work, as in max-sm:list-gap-3 sm:list-gap-4.

By default, clickable rows get 0.75rem of padding so their rounded hover surface clears the content. Static rows, the header and group headers get none, because a header cannot tell whether the rows below it are clickable. Setting --list-row-padding-x gives every row and the header the same value. So a table with clickable rows and a header needs list-row-px-3, or the header labels sit to the left of the cell text. In a selectable list, it also lines up the select-all checkbox with the row checkboxes.

To let the hover surface reach into the page gutter while the text stays lined up with the content above, pair -mx-3 with list-row-px-3.

Column widths are not a CSS variable. They come from the columns prop, so each list, nested ones included, has its own columns. Variables that start with --_list are internal and can change in any release.

Selection ​

selectable shows a checkbox column and makes a row click toggle the row instead of opening it. v-model:selection holds the selected row values.

With a ListHeader, a select-all checkbox appears in the header. It is checked when every row is selected and mixed when only some are, and it selects or clears all rows. It counts every item passed to ListRows, so it covers rows that virtual scrolling has not mounted.

Active row ​

A list with a detail pane, such as a mail inbox or a file browser, tracks one open row. Bind v-model:active to a row value. The list highlights that row and hides the dividers above and below it.

Clicking a row sets active, and the row's own @click and route still run. Only one row is active at a time. It is separate from the checkbox selection, and works in both layouts.

vue
<List v-model:active="openId">
  <ListRows :items="threads" v-slot="{ value }">
    <ListRow :value="value">…</ListRow>
  </ListRows>
</List>

Row values ​

Selection and the active row use each row's value. ListRows works out the value once per item and passes it to its slot as value. It reads the item's name, then id, then falls back to the index. Pass row-key to use another field, or a function (item, index) => key.

The ListRows slot receives { item, index, value, selected, active }. selected and active are separate states.

Sorting ​

ListHeaderCell is a plain label, with optional #prefix and #suffix slots. A sortable column uses ListHeaderCellSort instead. It is controlled: pass the current direction (asc, desc or null) and update your own sort state in its click handler. Your code decides the toggle rules and whether rows are sorted in the browser or through useList's orderBy.

The #sort-indicator slot receives { direction } and draws the direction icon. The cell renders a real button with a tooltip ("Order by name"), and shows an inactive column's indicator on hover. align="end" right-aligns the cell for a number column and moves the indicator before the label, so the label lines up with the values below. Plain and sortable cells can sit in the same header.

Responsive columns ​

Pass columns as an object keyed by breakpoint to change the template with the viewport width:

vue
<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 Tailwind screens and applies from that width up, until the next key. A breakpoint you leave out keeps the template below it: above, sm uses base and xl uses lg.

Each breakpoint replaces the whole template. Nothing is merged track by track, so a breakpoint can change the number of tracks as well as their widths.

The switch happens in CSS, at your app's breakpoint values, so the list's columns and your md:hidden classes change at the same width. The markup rendered on the server is already correct: there is no viewport measurement and no flash on first paint.

A key can be any kind of Tailwind screen. A { min, max } screen gives a template that ends where the screen ends, a { max } screen one that applies below a width, and a { raw } screen one that applies wherever its media query matches. Each template applies in the same places as that screen's own classes. Where two screens match at once, the template that wins is the one whose classes win.

A key that is not one of your screens is ignored. { base: […], medium: […] } uses base at every width, because medium names no breakpoint. The type cannot reject the key, because apps can define their own screens. A development build warns instead, names the key and lists 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.

Production builds leave the warning out. It needs frappe-ui's Tailwind preset, which tells the list which screens your app defines. Without the preset every key above base is ignored, and the warning says so.

Fewer tracks never hide a cell. Hide it yourself, with the same classes on the header cell and the row cells:

vue
<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>

Nested lists ​

Each List has its own columns. A list inside another list uses its own columns prop, or the feed template when it has none. It never takes the outer list's template.

Row height ​

rowHeight sets a fixed row height in pixels. Without it, rows size to their content. It is one number at every width, because virtual scrolling depends on it. For a list that is not virtual, set responsive heights with height classes on the rows.

Virtual rows ​

With virtual, ListRows mounts only the rows near the visible part of the scroll area. The row height comes from the parent List's rowHeight. overscan sets how many extra rows mount on each side, and defaults to 6.

The list scrolls inside the nearest scrollable parent, such as a settings panel or the page, and keeps that parent's scrollbar.

Groups ​

ListGroup puts a label row above its rows. Pass the text as label, or replace it with the #label slot. sticky pins the label to the top of the scroll area while its rows scroll under it.

Accessibility ​

The roles follow the header. Without a ListHeader, the list has role="list" and each row listitem. With one, it has table, row, columnheader and cell, and a sorted column has aria-sort.

The active row has aria-current. The select-all checkbox is named "Select all" and reports a mixed state when only some rows are selected. A ListGroup has role="rowgroup" and is named by its label.

API Reference ​

List ​

Show types
typescript
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'
}
columns
ListColumns

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
ListDivider

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.

selectable
boolean

Reveals the animated checkbox column and switches row click from navigate to toggle. Selected values surface via `v-model:selection`.

rowHeight
number

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.

selection
= []
string[]

The checkbox-selected row values, when `selectable` reveals the checkbox column. Two-way — toggling a row's checkbox updates this set.

active
string

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`.

default
—

The list's rows — `<ListRow>` / `<ListRows>`, optionally under `<ListHeader>` / `<ListGroup>`.

update:selection
[value: string[]]

Fired when the selection changes.

update:active
[value: string | undefined]

Fired when the active changes.

ListRow ​

Show types
typescript
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'
}
route
string | kt | Tt

Renders the row as a RouterLink. Without `route` or `href`, a row with a click listener renders as a button; otherwise a plain div.

href
string

External URL. Used when `route` is absent; renders a native same-tab anchor.

value
string

Row key — the `selection` key when `selectable` and the `v-model:active` key. Required whenever the list uses either.

onClick
((event: MouseEvent) => void)

Fired when the row is activated, unless selection mode claims the click.

default
—

The row's cells — `<ListCell>` elements, or feed content directly.

ListCell ​

default
—

The cell's content.

ListHeader ​

default
—

The header's columns — `<ListHeaderCell>` / `<ListHeaderCellSort>` elements.

ListHeaderCell ​

default
—

Column label.

prefix
—

Leading adornment, rendered before the label.

suffix
—

Trailing adornment, rendered after the label.

ListHeaderCellSort ​

Show types
typescript
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'
}
direction
ListSortDirection | null

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.

align
"start" | "end"

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'`.

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.

click
[event: MouseEvent]

Fired on sort button click — update your sort state here.

ListRows ​

Show types
typescript
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*
T[]

Items to iterate — one default-slot render per item.

rowKey
string | ((item: T, index: number) => PropertyKey)

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.

virtual
boolean

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.

overscan
number

Rows rendered beyond the visible window on each side. Default: `6`.

default
{ item: T; index: number; value: string; selected: boolean; active: boolean; }

One render per item. `active` and `selected` are independent row states.

ListGroup ​

Show types
typescript
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'
}
label
string

Section label shown in the group header. Overridden by the #label slot.

sticky
boolean

Pin the group header to the top of the scroll container while its rows scroll under it. Off by default.

default
—

The group's rows — `<ListRow>` elements.

label
—

Replaces the group label.