Skip to content

@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

PropTypeDefaultDescription
variantListVariant"default"Visual style
sizeListSize"md"Item density
dividedbooleanfalseAuto borders between items
stripedbooleanfalseAlternating backgrounds
hoverablebooleanfalseHover highlight on items
interactivebooleanfalsePointer cursor + active press
flushbooleanfalseStrip all container chrome
reorderablebooleanfalseEnable drag-and-drop reorder
selectionModeListSelectionMode"none"Selection behaviour
selectedstring | string[]Selected value(s) (v-model:selected)
ariaLabelstringAccessible label
tagstring"div"Container HTML element

Events

EventPayloadDescription
update:selectedstring | string[]Selection changed
item-clickstring, MouseEventItem clicked (with value)
reorderListReorderEventItem 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

PropTypeDefaultDescription
valuestringIdentifier (required for selection / reorder)
labelstringLabel text (alternative to default slot)
descriptionstringDescription text (alternative to slot)
selectedbooleanOverride selected state
disabledbooleanfalseDisabled state

Slots

SlotDescription
defaultLabel content (inside .list-item-label)
iconLeading icon
descriptionSecondary text below label
trailTrailing content (badges, actions, metadata)
handleCustom drag handle (default: 6-dot grip SVG)

Events

EventPayloadDescription
clickMouseEventItem 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

PropTypeDefaultDescription
labelstringGroup heading (alternative to label slot)

Slots

SlotDescription
defaultGroup items
labelCustom 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

PropTypeDefaultDescription
messagestringText (alternative to default slot)

Selection Modes

ModeBehaviour
"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 value prop
  • Items become draggable via HTML5 Drag and Drop
  • During drag: source item gets data-dragging, target gets data-drag-over="above" or data-drag-over="below" indicating cursor position relative to the target
  • On drop: reorder event emits { from, to, position } where position is "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

KeyAction
ArrowDownFocus next item (wraps)
ArrowUpFocus previous item (wraps)
HomeFocus first item
EndFocus last item
SpaceToggle selection (when selection enabled)
EnterActivate / toggle selection

ARIA

  • Default list: role="list" on container
  • Selectable list: role="listbox" on container, role="option" on items, aria-selected tracks 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

PropTypeDefaultDescription
itemsTransferItem[]All available items
modelValuestring[][]Values currently in the target list
sourceTitlestring"Available"Source panel heading
targetTitlestring"Selected"Target panel heading
emptyTextstring"No items"Empty state text
reorderablebooleanfalseAllow reordering in target
filterablebooleanfalseShow filter inputs
crossDragbooleanfalseEnable cross-list drag and drop
variantListVariant"outlined"Visual variant for both panels
sizeListSize"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

PropTypeDefaultDescription
panelIdstringUnique panel ID for drag origin/target identification
itemsTransferPanelItem[]Items to display
titlestring""Panel header title
showCountbooleantrueShow item count in header
checkablebooleanfalseAllow checking items for button-based transfer
draggablebooleanfalseAllow dragging items out
acceptsboolean | string[]falseAccept drops — true = any panel, string[] = listed panel IDs
reorderablebooleanfalseAllow reordering within this panel
filterablebooleanfalseShow filter/search input
emptyTextstring"No items"Placeholder for empty state
variantListVariant"outlined"Visual variant
sizeListSize"md"Size

Events

EventPayloadDescription
drop-inTransferDropEventItem dropped into this panel
drag-outTransferDragOutEventItem dragged out
reorder{ from: string, to: string, position: DragPosition }Items reordered
update:checkedstring[]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

PackagePurpose
@carrot/design-components-listsCSS tokens + generated styles

Build

bash
npm run build

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

Carrot