Appearance
@carrot/design-vue-lists
Vue 3 list components for the Carrot Design System. Compound component architecture with selection, drag-and-drop reorder, keyboard navigation, and full ARIA support.
Installation
ts
import { CarrotList, CarrotListItem, CarrotListGroup, CarrotListDivider, CarrotListEmpty, CarrotTransferList, CarrotTransferPanel } from "@carrot/design-vue-lists";Requires the CSS layer from @carrot/design-components-lists.
Components
CarrotList
Root container that provides shared context to all list children via inject.
Usage
vue
<!-- Basic -->
<CarrotList>
<CarrotListItem label="Notifications" description="Manage your notification preferences" />
<CarrotListItem label="Security" description="Password, 2FA, and sessions" />
<CarrotListItem label="Billing" description="Plans, invoices, and payment methods" />
</CarrotList>
<!-- Interactive with single selection -->
<CarrotList interactive hoverable selection-mode="single" v-model:selected="selectedItem">
<CarrotListItem value="a" label="Option A" />
<CarrotListItem value="b" label="Option B" />
<CarrotListItem value="c" label="Option C" />
</CarrotList>
<!-- Multi-select -->
<CarrotList interactive hoverable selection-mode="multi" v-model:selected="selectedItems">
<CarrotListItem value="one" label="Item 1" />
<CarrotListItem value="two" label="Item 2" />
<CarrotListItem value="three" label="Item 3" />
</CarrotList>
<!-- Reorderable -->
<CarrotList reorderable @reorder="onReorder">
<CarrotListItem v-for="item in items" :key="item.id" :value="item.id" :label="item.name" />
</CarrotList>
<!-- Variants -->
<CarrotList variant="elevated">...</CarrotList>
<CarrotList variant="outlined">...</CarrotList>
<CarrotList variant="ghost" flush>...</CarrotList>
<!-- Density -->
<CarrotList size="sm">...</CarrotList>
<CarrotList size="lg" divided>...</CarrotList>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ListVariant | "default" | Visual style |
size | ListSize | "md" | Item density |
divided | boolean | false | Auto borders between items |
striped | boolean | false | Alternating backgrounds |
hoverable | boolean | false | Hover highlight on items |
interactive | boolean | false | Pointer cursor + active press |
flush | boolean | false | Strip all container chrome |
reorderable | boolean | false | Enable drag-and-drop reorder |
selectionMode | ListSelectionMode | "none" | Selection behaviour |
selected | string | string[] | — | Selected value(s) (v-model:selected) |
ariaLabel | string | — | Accessible label |
tag | string | "div" | Container HTML element |
Events
| Event | Payload | Description |
|---|---|---|
update:selected | string | string[] | Selection changed |
item-click | string, MouseEvent | Item clicked (with value) |
reorder | ListReorderEvent | Item dropped onto new target |
CarrotListItem
Individual list row with structured slots.
Usage
vue
<!-- Simple -->
<CarrotListItem label="Settings" />
<!-- With description -->
<CarrotListItem label="Notifications" description="Email, push, and in-app alerts" />
<!-- Full slots -->
<CarrotListItem value="doc-1">
<template #icon><FileIcon /></template>
Report Q3.pdf
<template #description>2.4 MB · Modified today</template>
<template #trail>
<CarrotBadge label="New" variant="accent-subtle" />
</template>
</CarrotListItem>
<!-- Disabled -->
<CarrotListItem label="Locked" disabled />Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Identifier (required for selection / reorder) |
label | string | — | Label text (alternative to default slot) |
description | string | — | Description text (alternative to slot) |
selected | boolean | — | Override selected state |
disabled | boolean | false | Disabled state |
Slots
| Slot | Description |
|---|---|
default | Label content (inside .list-item-label) |
icon | Leading icon |
description | Secondary text below label |
trail | Trailing content (badges, actions, metadata) |
handle | Custom drag handle (default: 6-dot grip SVG) |
Events
| Event | Payload | Description |
|---|---|---|
click | MouseEvent | Item clicked |
CarrotListGroup
Section wrapper with label heading.
vue
<CarrotList>
<CarrotListGroup label="General">
<CarrotListItem label="Profile" />
<CarrotListItem label="Language" />
</CarrotListGroup>
<CarrotListGroup label="Danger Zone">
<CarrotListItem label="Delete account" />
</CarrotListGroup>
</CarrotList>Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Group heading (alternative to label slot) |
Slots
| Slot | Description |
|---|---|
default | Group items |
label | Custom heading |
CarrotListDivider
Visual separator between items.
vue
<CarrotList>
<CarrotListItem label="Above" />
<CarrotListDivider />
<CarrotListItem label="Below" />
</CarrotList>CarrotListEmpty
Empty state displayed when the list has no items.
vue
<CarrotList>
<CarrotListEmpty v-if="items.length === 0" message="No results found" />
<CarrotListItem v-for="item in items" :key="item.id" :label="item.name" />
</CarrotList>Props
| Prop | Type | Default | Description |
|---|---|---|---|
message | string | — | Text (alternative to default slot) |
Selection Modes
| Mode | Behaviour |
|---|---|
"none" (default) | No selection. Items can still be interactive/clickable. |
"single" | One item at a time. Re-selecting the same item deselects. |
"multi" | Toggle items independently. Multiple can be selected. |
When selection is enabled, the container uses role="listbox" and items use role="option" with aria-selected.
Drag and Drop Reorder
When reorderable is set:
- A drag handle appears on each item that has a
valueprop - Items become draggable via HTML5 Drag and Drop
- During drag: source item gets
data-dragging, target getsdata-drag-over="above"ordata-drag-over="below"indicating cursor position relative to the target - On drop:
reorderevent emits{ from, to, position }wherepositionis"above" | "below" - Your code handles the actual array reorder
vue
<script setup>
function onReorder({ from, to, position }) {
const values = items.value.map((i) => i.id);
const fromIdx = values.indexOf(from);
const [moved] = items.value.splice(fromIdx, 1);
const toIdx = items.value.findIndex((i) => i.id === to);
const insertIdx = position === "above" ? toIdx : toIdx + 1;
items.value.splice(insertIdx, 0, moved);
}
</script>
<CarrotList reorderable @reorder="onReorder">
<CarrotListItem v-for="item in items" :key="item.id" :value="item.id" :label="item.name" />
</CarrotList>Keyboard Navigation
| Key | Action |
|---|---|
| ArrowDown | Focus next item (wraps) |
| ArrowUp | Focus previous item (wraps) |
| Home | Focus first item |
| End | Focus last item |
| Space | Toggle selection (when selection enabled) |
| Enter | Activate / toggle selection |
ARIA
- Default list:
role="list"on container - Selectable list:
role="listbox"on container,role="option"on items,aria-selectedtracks state - Multi-select:
aria-multiselectable="true"on container - Disabled items:
aria-disabled="true" - Groups:
role="group"on group wrapper - Dividers:
role="separator" - Empty state:
role="status"
CarrotTransferList
Dual-panel transfer list. Items can be moved between a source ("Available") and target ("Selected") panel using check-and-transfer buttons, cross-list drag-and-drop, or both.
Usage
vue
<script setup>
import { ref } from "vue";
const items = [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Bravo" },
{ value: "c", label: "Charlie" },
];
const selected = ref(["a"]);
</script>
<template>
<CarrotTransferList
:items="items"
v-model="selected"
filterable
reorderable
cross-drag
/>
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TransferItem[] | — | All available items |
modelValue | string[] | [] | Values currently in the target list |
sourceTitle | string | "Available" | Source panel heading |
targetTitle | string | "Selected" | Target panel heading |
emptyText | string | "No items" | Empty state text |
reorderable | boolean | false | Allow reordering in target |
filterable | boolean | false | Show filter inputs |
crossDrag | boolean | false | Enable cross-list drag and drop |
variant | ListVariant | "outlined" | Visual variant for both panels |
size | ListSize | "md" | Size for both panels |
CarrotTransferPanel
Standalone transfer panel for building custom multi-panel transfer layouts.
Usage
vue
<CarrotTransferPanel
panel-id="source"
:items="sourceItems"
title="Available"
draggable
checkable
filterable
@drag-out="onDragOut"
/>
<CarrotTransferPanel
panel-id="target"
:items="targetItems"
title="Selected"
:accepts="['source']"
reorderable
filterable
@drop-in="onDropIn"
@reorder="onReorder"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
panelId | string | — | Unique panel ID for drag origin/target identification |
items | TransferPanelItem[] | — | Items to display |
title | string | "" | Panel header title |
showCount | boolean | true | Show item count in header |
checkable | boolean | false | Allow checking items for button-based transfer |
draggable | boolean | false | Allow dragging items out |
accepts | boolean | string[] | false | Accept drops — true = any panel, string[] = listed panel IDs |
reorderable | boolean | false | Allow reordering within this panel |
filterable | boolean | false | Show filter/search input |
emptyText | string | "No items" | Placeholder for empty state |
variant | ListVariant | "outlined" | Visual variant |
size | ListSize | "md" | Size |
Events
| Event | Payload | Description |
|---|---|---|
drop-in | TransferDropEvent | Item dropped into this panel |
drag-out | TransferDragOutEvent | Item dragged out |
reorder | { from: string, to: string, position: DragPosition } | Items reordered |
update:checked | string[] | Checked items changed |
Types
DragPosition
ts
type DragPosition = "above" | "below";Used in ListReorderEvent and CarrotTransferPanel's reorder event to indicate whether the dragged item was dropped above or below the target.
Dependencies
| Package | Purpose |
|---|---|
@carrot/design-components-lists | CSS tokens + generated styles |
Build
bash
npm run buildBuilt with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.
Usage Guide
Setup
ts
import { CarrotList, CarrotListItem, CarrotListGroup, CarrotListDivider, CarrotListEmpty, CarrotTransferList, CarrotTransferPanel } from "@carrot/design-vue-lists";
import "@carrot/design-components-lists";CarrotList
Settings navigation list
vue
<script setup lang="ts">
import { CarrotList, CarrotListItem, CarrotListGroup, CarrotListDivider } from "@carrot/design-vue-lists";
import { useRouter } from "vue-router";
const router = useRouter();
</script>
<template>
<CarrotList variant="outlined" hoverable interactive divided>
<CarrotListGroup label="Account">
<CarrotListItem label="Profile" description="Edit your public profile" @click="router.push('/settings/profile')" />
<CarrotListItem label="Notifications" description="Email, push, and in-app alerts" @click="router.push('/settings/notifications')" />
</CarrotListGroup>
<CarrotListDivider />
<CarrotListGroup label="Security">
<CarrotListItem label="Password" description="Change your password" @click="router.push('/settings/password')" />
<CarrotListItem label="Two-factor authentication" @click="router.push('/settings/2fa')" />
</CarrotListGroup>
</CarrotList>
</template>Single-select list
vue
<script setup lang="ts">
import { CarrotList, CarrotListItem } from "@carrot/design-vue-lists";
import { ref } from "vue";
const selectedGenre = ref("electronic");
const genres = [
{ id: "electronic", label: "Electronic" },
{ id: "jazz", label: "Jazz" },
{ id: "hip-hop", label: "Hip Hop" },
{ id: "classical", label: "Classical" },
];
</script>
<template>
<CarrotList
interactive
hoverable
selection-mode="single"
v-model:selected="selectedGenre"
>
<CarrotListItem
v-for="genre in genres"
:key="genre.id"
:value="genre.id"
:label="genre.label"
/>
</CarrotList>
</template>Reorderable playlist
vue
<script setup lang="ts">
import { CarrotList, CarrotListItem } from "@carrot/design-vue-lists";
import { CarrotBadge } from "@carrot/design-vue-badges";
import { ref } from "vue";
import type { ListReorderEvent } from "@carrot/design-vue-lists";
const tracks = ref([
{ id: "t1", title: "Acid Rain", duration: "6:42" },
{ id: "t2", title: "Dub Horizon", duration: "8:15" },
{ id: "t3", title: "System Error", duration: "5:30" },
]);
function onReorder({ from, to, position }: ListReorderEvent) {
const fromIdx = tracks.value.findIndex((t) => t.id === from);
const [moved] = tracks.value.splice(fromIdx, 1);
const toIdx = tracks.value.findIndex((t) => t.id === to);
const insertIdx = position === "above" ? toIdx : toIdx + 1;
tracks.value.splice(insertIdx, 0, moved);
}
</script>
<template>
<CarrotList reorderable divided @reorder="onReorder">
<CarrotListItem
v-for="track in tracks"
:key="track.id"
:value="track.id"
:label="track.title"
>
<template #trail>
<span>{{ track.duration }}</span>
</template>
</CarrotListItem>
</CarrotList>
</template>Common Patterns
List with empty state
vue
<script setup lang="ts">
import { CarrotList, CarrotListItem, CarrotListEmpty } from "@carrot/design-vue-lists";
import { computed } from "vue";
const props = defineProps<{ items: Array<{ id: string; label: string }> }>();
</script>
<template>
<CarrotList>
<CarrotListEmpty v-if="props.items.length === 0" message="No items found" />
<CarrotListItem
v-for="item in props.items"
:key="item.id"
:value="item.id"
:label="item.label"
/>
</CarrotList>
</template>Multi-select with trail badges
vue
<script setup lang="ts">
import { CarrotList, CarrotListItem } from "@carrot/design-vue-lists";
import { CarrotBadge } from "@carrot/design-vue-badges";
import { ref } from "vue";
const selected = ref<string[]>([]);
const artists = [
{ id: "a1", name: "Burial", genre: "UK Garage" },
{ id: "a2", name: "Four Tet", genre: "Electronica" },
{ id: "a3", name: "Aphex Twin", genre: "Ambient" },
];
</script>
<template>
<CarrotList interactive hoverable selection-mode="multi" v-model:selected="selected">
<CarrotListItem
v-for="artist in artists"
:key="artist.id"
:value="artist.id"
:label="artist.name"
>
<template #trail>
<CarrotBadge :label="artist.genre" auto-color size="sm" />
</template>
</CarrotListItem>
</CarrotList>
<p>{{ selected.length }} selected</p>
</template>CarrotTransferList
Permission assignment
vue
<script setup lang="ts">
import { CarrotTransferList } from "@carrot/design-vue-lists";
import { ref } from "vue";
const permissions = [
{ value: "read", label: "Read", description: "View content" },
{ value: "write", label: "Write", description: "Create and edit content" },
{ value: "delete", label: "Delete", description: "Remove content" },
{ value: "admin", label: "Admin", description: "Full system access" },
];
const assigned = ref(["read"]);
</script>
<template>
<CarrotTransferList
:items="permissions"
v-model="assigned"
source-title="Available Permissions"
target-title="Assigned Permissions"
filterable
reorderable
cross-drag
/>
</template>