CList
CList is a generic list container that supports single and multiple selection, keyboard navigation, typeahead, readonly/disabled states and ARIA roles. It works together with CListItem via provide/inject.
The interaction model is driven by the variant prop:
variantlist (default)listboxrole="listbox"option items, aria-selected).menurole="menu"listbox; only ARIA differs (menuitem items, no aria-selected).Plain list
With variant="list" (the default) the list is non-interactive: items are not registered, not selectable and stay out of keyboard navigation. This is handy for static information display, where CListItem and the helper components are used purely for layout and typography.
Show code
<template>
<c-card class="elevation-3 radius-16" style="width:360px;overflow:hidden">
<div class="px-4 pt-4 pb-2 fs-xs fw-semi-bold text-uppercase text-blue-grey">
Contact details
</div>
<c-list class="pa-2">
<c-list-item v-for="d in details" :key="d.label" class="px-3 py-2">
<c-list-item-icon>
<c-icon :name="d.icon" source="fa" :size="14" class="text-blue-grey" />
</c-list-item-icon>
<c-list-item-content>
<c-list-item-subtitle>{{ d.label }}</c-list-item-subtitle>
<c-list-item-title class="fw-medium">{{ d.value }}</c-list-item-title>
</c-list-item-content>
</c-list-item>
</c-list>
</c-card>
</template>
<script setup lang="ts">
const details = [
{ icon: 'fas:user', label: 'Full name', value: 'Anna Smith' },
{ icon: 'fas:envelope', label: 'Email', value: 'anna.smith@example.com' },
{ icon: 'fas:phone', label: 'Phone', value: '+1 (555) 123-4567' },
{ icon: 'fas:briefcase', label: 'Company', value: 'Vueland Inc.' },
{ icon: 'fas:map-marker-alt', label: 'Location', value: 'Berlin, Germany' },
]
</script>Basic usage
Use variant="listbox" to make items selectable and bind the selection with v-model.
Show code
<template>
<c-card class="elevation-3 radius-16" style="width:300px;overflow:hidden">
<div class="px-4 pt-4 pb-2 fs-xs fw-semi-bold text-uppercase text-blue-grey">
Display density
</div>
<c-list v-model="selected" variant="listbox" mandatory class="pa-2">
<c-list-item
v-for="d in densities"
:key="d.value"
:value="d.value"
class="px-3 py-2 radius-8"
>
<c-list-item-content>
<c-list-item-title class="fw-medium">{{ d.label }}</c-list-item-title>
<c-list-item-subtitle>{{ d.hint }}</c-list-item-subtitle>
</c-list-item-content>
<c-icon v-if="selected === d.value" name="fas:check" source="fa" :size="14" />
</c-list-item>
</c-list>
</c-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref('comfortable')
const densities = [
{ value: 'compact', label: 'Compact', hint: 'Tight spacing' },
{ value: 'comfortable', label: 'Comfortable', hint: 'Balanced (default)' },
{ value: 'spacious', label: 'Spacious', hint: 'Roomy layout' },
]
</script>Multiple selection
Add multiple to collect values into an array.
Show code
<template>
<c-list v-model="selected" variant="listbox" multiple class="pa-2">
<c-list-item v-for="s in skills" :key="s.value" :value="s.value" class="px-3 py-2 radius-8">
<c-list-item-icon>
<c-icon :name="s.icon" source="fa" :size="14" />
</c-list-item-icon>
<span class="grow-1 fw-medium">{{ s.label }}</span>
<c-icon v-if="selected.includes(s.value)" name="fas:check" source="fa" :size="14" />
</c-list-item>
</c-list>
<c-row class="gap-y-2 mt-4" align="center">
<c-col v-for="s in selected" :key="s" cols="6" class="d-flex justify-center">
<span class="radius-pill bg-teal text-white px-3 py-1 fs-xs fw-semi-bold text-capitalize">
{{ s }}
</span>
</c-col>
</c-row>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref<string[]>(['frontend', 'design'])
const skills = [
{ value: 'design', label: 'Design', icon: 'fas:pen' },
{ value: 'frontend', label: 'Frontend', icon: 'fas:code' },
{ value: 'backend', label: 'Backend', icon: 'fas:box' },
{ value: 'devops', label: 'DevOps', icon: 'fas:cog' },
{ value: 'qa', label: 'QA', icon: 'fas:shield-alt' },
]
</script>multiple is not limited to listbox — it works in any interactive variant.
Rich items
CListItem lays out its default slot as a flex row, so you can freely compose icons, text and trailing content. The library ships helper components for consistent structure and typography:
CListItemIcon— a leading/trailing icon slot.CListItemContent— a column wrapper that stacks the title and subtitle.CListItemTitle/CListItemSubtitle— primary and secondary text.
Show code
<template>
<c-card class="elevation-3 radius-16" style="width:340px;overflow:hidden">
<c-list v-model="selected" variant="listbox" mandatory class="pa-2">
<c-list-item v-for="f in folders" :key="f.value" :value="f.value" class="px-3 py-2 radius-8">
<span
class="d-inline-flex items-center justify-center radius-circle text-white"
:class="f.bg"
style="width:38px;height:38px;flex-shrink:0"
>
<c-icon :name="f.icon" source="fa" :size="15" />
</span>
<c-list-item-content>
<c-list-item-title class="fw-medium">{{ f.title }}</c-list-item-title>
<c-list-item-subtitle>{{ f.subtitle }}</c-list-item-subtitle>
</c-list-item-content>
<span
v-if="f.badge"
class="badge fs-xs fw-semi-bold"
:class="selected === f.value ? 'badge--active' : 'bg-pink text-white'"
>
{{ f.badge }}
</span>
</c-list-item>
</c-list>
</c-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref('inbox')
const folders = [
{
value: 'inbox',
icon: 'fas:envelope',
bg: 'bg-indigo',
title: 'Inbox',
subtitle: '12 unread messages',
badge: '12',
},
{
value: 'starred',
icon: 'fas:star',
bg: 'bg-amber',
title: 'Starred',
subtitle: '3 conversations',
badge: '3',
},
{
value: 'sent',
icon: 'fas:share-alt',
bg: 'bg-teal',
title: 'Sent',
subtitle: 'Last sent 2h ago',
badge: '',
},
{
value: 'trash',
icon: 'fas:trash',
bg: 'bg-blue-grey',
title: 'Trash',
subtitle: 'Empty',
badge: '',
},
]
</script>
<style scoped>
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
}
/* On a selected (primary) row the badge inverts: surface bg + primary text */
.badge--active {
background: var(--c-sys-color-on-primary);
color: var(--c-sys-color-primary);
}
</style>Menu variant
Use variant="menu" for action lists. Items render as menuitem (no aria-selected). Enter / Space and click both trigger the item's own click — so a menu item's @click runs from the keyboard too, and the value toggles just like in listbox. Only the ARIA role differs.
Show code
<template>
<c-card class="elevation-4 radius-12" style="width:250px;overflow:hidden">
<c-list variant="menu" class="pa-2">
<c-list-item
v-for="a in actions"
:key="a.value"
:value="a.value"
class="px-3 py-2 radius-8"
@click="last = a.label"
>
<c-list-item-icon>
<c-icon :name="a.icon" source="fa" :size="14" class="text-blue-grey" />
</c-list-item-icon>
<span class="grow-1 fw-medium">{{ a.label }}</span>
<span class="kbd fs-xs fw-medium">{{ a.kb }}</span>
</c-list-item>
<div class="divider my-1 mx-2" />
<c-list-item
value="delete"
class="px-3 py-2 radius-8 text-deep-orange"
@click="last = 'Delete'"
>
<c-list-item-icon>
<c-icon name="fas:trash" source="fa" :size="14" />
</c-list-item-icon>
<span class="grow-1 fw-medium">Delete</span>
</c-list-item>
</c-list>
</c-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const last = ref('')
const actions = [
{ value: 'copy', icon: 'fas:copy', label: 'Copy', kb: '⌘C' },
{ value: 'cut', icon: 'fas:cut', label: 'Cut', kb: '⌘X' },
{ value: 'paste', icon: 'fas:paste', label: 'Paste', kb: '⌘V' },
{ value: 'rename', icon: 'fas:pen', label: 'Rename…', kb: 'F2' },
]
</script>
<style scoped>
.kbd {
padding: 2px 6px;
border-radius: 6px;
font-size: 12px;
background: var(--c-sys-color-surface-variant);
}
.divider {
height: 1px;
background: var(--c-sys-color-outline-variant, rgba(0, 0, 0, 0.1));
}
</style>Comparing object values
By default values are matched by reference (via toRaw), which is enough for primitives and stable object references. When your values are objects coming from different sources (e.g. re-fetched from an API), pass item-key to compare them by a field or a custom function.
item-key must resolve to a unique value per item. If two items share the same key (e.g. objects with a non-unique title), they are treated as the same value — selecting one marks both. Prefer a stable id field over a display field.
In the demo below, the initial selection is a different object reference than the one in the list — item-key="id" is what makes it match and render as selected.
Show code
<template>
<c-card class="elevation-3 radius-16" style="width:360px;overflow:hidden">
<c-list v-model="selected" variant="listbox" item-key="id" multiple class="pa-2">
<c-list-item v-for="u in users" :key="u.id" :value="u" class="px-3 py-2 radius-8">
<span
class="d-inline-flex items-center justify-center radius-circle text-white fs-sm fw-semi-bold"
:class="u.bg"
style="width:38px;height:38px;flex-shrink:0"
>
{{ u.initials }}
</span>
<c-list-item-content>
<c-list-item-title class="fw-medium">{{ u.name }}</c-list-item-title>
<c-list-item-subtitle>{{ u.role }}</c-list-item-subtitle>
</c-list-item-content>
<c-icon v-if="selectedIds.includes(u.id)" name="fas:check" source="fa" :size="15" />
</c-list-item>
</c-list>
</c-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type User = { id: number; name: string; role: string; initials: string; bg: string }
const users: User[] = [
{ id: 1, name: 'Anna Smith', role: 'Product Designer', initials: 'AS', bg: 'bg-pink' },
{ id: 2, name: 'Boris Lee', role: 'Software Engineer', initials: 'BL', bg: 'bg-indigo' },
{ id: 3, name: 'Clara Diaz', role: 'Product Manager', initials: 'CD', bg: 'bg-teal' },
{ id: 4, name: 'Dmitri Orlov', role: 'QA Engineer', initials: 'DO', bg: 'bg-deep-orange' },
]
// Different object reference than the list entry — item-key="id" makes it match.
const selected = ref<User[]>([{ ...users[1] }])
const selectedIds = computed(() => selected.value.map((u) => u.id))
</script>item-key also accepts a function:
<c-list v-model="selected" variant="listbox" :item-key="(item) => item.id">
<!-- ... -->
</c-list>Mandatory selection
With mandatory, the currently selected item cannot be deselected. In multiple mode, the last remaining item cannot be removed.
<c-list v-model="tab" variant="listbox" mandatory>
<c-list-item value="tab1">Tab 1</c-list-item>
<c-list-item value="tab2">Tab 2</c-list-item>
</c-list>Readonly and disabled
readonly keeps the current selection visible but blocks any changes. disabled additionally dims the list and removes it from the tab order. Individual items can be disabled with the disabled prop — they keep their ARIA contract but are skipped by selection, hover, click and keyboard navigation.
<c-list v-model="selected" variant="listbox" readonly>
<c-list-item value="a">Option A</c-list-item>
<c-list-item value="b">Option B</c-list-item>
</c-list>
<c-list v-model="selected" variant="listbox">
<c-list-item value="a">Available</c-list-item>
<c-list-item value="b" disabled>Sold out</c-list-item>
<c-list-item value="c">Available</c-list-item>
</c-list>Keyboard navigation
When variant is listbox or menu, the list is part of the tab order (tabindex="0") and can be focused directly. It can also be focused programmatically via the exposed focus() method.
ArrowDown / ArrowUpHome / EndEnter / Space@click runs)<template>
<c-list ref="listRef" variant="menu">
<c-list-item value="cut">Cut</c-list-item>
<c-list-item value="copy">Copy</c-list-item>
<c-list-item value="paste">Paste</c-list-item>
</c-list>
<c-btn @click="listRef?.focus()">Focus menu</c-btn>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const listRef = ref()
</script>Custom rendering
The default slot is bound to the full list API — the very same ListAPI contract that CListItem consumes internally via inject. There is no separate "slot-only" surface: whatever the component uses to drive selection is exactly what the slot hands you, so you can re-render items with any components and keep the behaviour.
For selection you only need toggle (or the granular select / unselect) and isActive:
<template>
<c-list v-model="selected" variant="listbox">
<template #default="{ toggle, isActive }">
<c-chip
v-for="item in items"
:key="item"
:class="{ 'is-active': isActive(item) }"
@click="toggle(item)"
>
{{ item }}
</c-chip>
</template>
</c-list>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const items = ['one', 'two', 'three']
const selected = ref<string | null>(null)
</script>API
CList props
modelValueT | T[] | nullnullvariant'list' | 'listbox' | 'menu''list'multiplebooleanfalsevariant)mandatorybooleanfalsemultiple: prevent removing the last item)readonlybooleanfalsedisabledbooleanfalseitem-keystring | ((item: T) => unknown)CList events
update:modelValueT | T[] | nullCList slots
defaultListAPI<T>default slot props
The slot receives the whole ListAPI<T> — the same object provided to CListItem via inject. Selection-facing members:
toggle(item: T) => voidselect(item: T) => voidunselect(item: T) => voidisActive(item: T) => booleanrole'listbox' | 'menu' | undefinedFor rebuilding CListItem from scratch the same object also exposes registerItem / unregisterItem, so keyboard navigation can discover your items. Both take a ListItem controller.
CList expose
focus() => voidlistbox / menu)navigateFirst / navigateLast() => voidnavigateUp / navigateDown() => voidactivateItem() => void@click runs)onKeydown(e: KeyboardEvent) => voidCListItem props
valueTdisabledbooleanfalseCListItem events
activeid: stringinactiveid: stringCListItem slots
defaultvalue)Helper components
CListItemIcon<div class="c-list-item-icon">CListItemContent<div class="c-list-item-content">CListItemTitle<div class="c-list-item-title">CListItemSubtitle<div class="c-list-item-subtitle">Accessibility
role (list)variant="listbox" / "menu""listbox" / "menu"role (item)"option" / "menuitem"aria-multiselectablelistbox + multiple"true"aria-selectedlistbox items"true" / "false"aria-disableddisabled list or item in a managed list"true"tabindex (list)listbox / menu, not disabled0CSS variables
CList:
--c-list-bg-colorvar(--c-sys-color-surface)--c-list-colorvar(--c-sys-color-on-surface)--c-list-border-radiusvar(--c-sys-shape-md)--c-list-padding-block0--c-list-padding-inline0--c-list-disabled-opacityvar(--c-sys-state-disabled-opacity)CListItem:
--c-list-item-min-heightvar(--c-sys-control-height-sm)--c-list-item-padding-blockvar(--c-sys-space-1)--c-list-item-padding-inlinevar(--c-sys-space-2)--c-list-item-gapvar(--c-sys-space-3)--c-list-item-border-radiusvar(--c-sys-shape-none)--c-list-item-colorcurrentColor--c-list-item-bg-colortransparent--c-list-item-selected-bg-colorvar(--c-sys-color-secondary-container)--c-list-item-selected-colorvar(--c-sys-color-primary)--c-list-item-state-layer-colortransparent--c-list-item-hover-bg-colorvar(--c-sys-state-hover-color)--c-list-item-focus-bg-colorvar(--c-sys-state-focus-color)--c-list-item-pressed-bg-colorvar(--c-sys-state-pressed-color)--c-list-item-focus-ring-colorvar(--c-sys-color-focus-ring)--c-list-item-disabled-opacityvar(--c-sys-state-disabled-opacity)--c-list-item-disabled-colorvar(--c-sys-color-disabled)--c-list-item-dragged-opacityvar(--c-sys-state-dragged-opacity)--c-list-item-title-font-sizevar(--c-sys-typography-body-size)--c-list-item-title-line-heightvar(--c-sys-typography-label-line-height)--c-list-item-title-font-weight400--c-list-item-subtitle-font-sizevar(--c-sys-typography-label-size)--c-list-item-subtitle-line-heightvar(--c-sys-typography-label-line-height)--c-list-item-subtitle-colorvar(--c-sys-color-on-surface-variant)--c-list-item-subtitle-opacity0.6