Documentação/Guias

Listas ordenáveis

Listas, barras de abas e grades que se reordenam enquanto você arrasta — makeSortableList renderiza um rascunho, makeSortableItem cria as linhas, e o drop grava a nova ordem no seu ref. Handles, uma linha de drop, trocas.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Live

Carregando a demo…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import LayersPanel from './LayersPanel.vue';

// Front to back, as a design tool lists them.
const layers = ref([
  { id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
  { id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
  { id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
  { id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>

<template>
  <DnDProvider>
    <div class="editor">
      <!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
      <div class="artboard" aria-hidden="true">
        <template v-for="(layer, index) in layers" :key="layer.id">
          <div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
            <template v-if="layer.id === 'title'">Summer sale</template>
            <template v-if="layer.id === 'badge'">-30%</template>
          </div>
        </template>
      </div>
      <LayersPanel v-model="layers" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.editor {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
}

.artboard {
  position: relative;
  overflow: hidden;
  width: 240px;
  height: 170px;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}

.shape {
  position: absolute;
}

.card {
  inset: 18px;
  border-radius: 12px;
  background: #1e3a8a;
}

.photo {
  top: 34px;
  left: 66px;
  width: 120px;
  height: 84px;
  border-radius: 8px;
  background:
    radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
    linear-gradient(170deg, #67e8f9, #0e7490);
}

.badge {
  top: 20px;
  left: 160px;
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: #facc15;
  color: #1e3a8a;
  font: 800 13px/1 system-ui, sans-serif;
}

.title {
  top: 104px;
  left: 30px;
  color: #fff;
  font: 800 24px/1 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>

Uma lista e as linhas dela

Uma lista ordenável precisa de dois componentes: a lista e a linha dela.

LayersPanel.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import LayerRow from './LayerRow.vue';

const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>

<template>
  <ul ref="list">
    <LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
  </ul>
</template>
LayerRow.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>

<template>
  <li ref="el">{{ layer.name }}</li>
</template>

A linha se registra na lista em que é renderizada. Um componente pode criar várias listas (duas colunas, uma lista dentro de outra): cada linha entra naquela cujo elemento a contém, a mais interna no caso de listas dentro de listas.

O rascunho

makeSortableList retorna items, um rascunho do seu array. Enquanto você arrasta, o rascunho se reordena ao vivo: a linha arrastada ocupa a nova posição, e as outras saem do caminho. O seu array não muda até o drop. Aí a biblioteca grava a nova ordem nele.

O painel de camadas acima mostra os dois: o painel renderiza items e se move enquanto você arrasta; a prancheta lê layers, o próprio array, e só reempilha quando você solta.

  • Renderize items, não o seu array. Renderizando o seu array, nada se move até o drop.
  • A gravação acontece depois do onDrop da lista. No onDrop, o seu array ainda tem a ordem antiga e e.result, a nova.
  • layers é um ref comum (aqui um defineModel, e a gravação emite update:modelValue), então o resto do seu app vê a mudança como qualquer outra.
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
Uma store, estado do servidor ou dados imutáveis? Passe um getter ou um computed em vez de um ref. A biblioteca não grava em uma fonte somente leitura: chame e.preventDefault() e aplique e.result você mesmo. Veja listas controladas.

Keys

As linhas são identificadas pelos dados, nunca pelo índice ou por um id automático. Por padrão, a key é item.id, ou o próprio item para strings e números.

// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });

// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
  • Use a mesma key no v-for: :key="layer.uuid". Com :key="index" as linhas erradas são animadas e o estado delas pula de uma linha para outra.
  • As keys precisam ser únicas entre todas as listas que trocam itens.
  • Passe item e id como getters, para que uma linha continue sincronizada quando as props dela mudam.

Handles

Por padrão, a linha inteira inicia um arraste, exceto os botões, inputs e outros controles dentro dela: o olho e o cadeado de uma camada continuam sendo botões comuns. Passe handle para arrastar por uma parte só:

LayerRow.vue
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>

<template>
  <li ref="el">
    <button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
    {{ layer.name }}
  </li>
</template>
  • handle é um seletor dentro do item, ou um ref de elemento.
  • Um arraste com o mouse começa pelo handle na hora, sem os 4 px de sempre. Uma pressão que não se move continua sendo um clique: o handle pode abrir um menu no click.
  • O handle recebe os atributos do arraste (role, tabindex, aria-*), e os arrastes por teclado começam por ele.

Faça do handle um <button type="button"> com um aria-label: ele já é focável, e os leitores de tela anunciam o rótulo. Um <span> simples com um ícone também funciona (a biblioteca dá a ele tabindex="0" e role="button"), mas não tem um nome significativo.

Arrastes por toque começam depois de um toque longo de 200 ms, para que a página ainda possa rolar. Para arrastar na hora a partir de um handle, dê ao handle touch-action: none e ao item activation: { touch: { distance: 4 } }.

Estratégias

strategy diz como a lista está organizada, para que as linhas abram espaço do jeito certo:

strategyPara
'vertical' (padrão)Uma coluna de linhas.
'horizontal'Uma única fileira: abas, chips, uma linha do tempo.
'grid'Grids CSS e fileiras flex que quebram linha, também com itens de tamanhos diferentes.

Uma fileira de abas é uma lista horizontal:

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>

<template>
  <DnDProvider>
    <Browser />
  </DnDProvider>
</template>
Browser.vue
const { items } = makeSortableList(strip, {
  items: tabs,
  strategy: 'horizontal',
  axis: 'x',        // the tabs stay on their row
  animateChanges: true, // the buttons open and close tabs: slide the others then too
});

O botão de fechar da aba continua funcionando dentro da aba arrastável, e um clique que termina um arraste não seleciona uma aba. As linhas deslizam sozinhas enquanto você arrasta; animateChanges faz o mesmo para mudanças que nenhum arraste fez, aqui os botões (veja animações).

Uma grade ordena em duas dimensões, com blocos de qualquer tamanho:

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>

<template>
  <DnDProvider>
    <PhotoGrid />
  </DnDProvider>
</template>
PhotoGrid.vue
const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }

grid-auto-flow: dense mantém a grade compacta quando um bloco grande se move. Sem isso, a grade deixa buracos onde um bloco não cabe mais, e ganha uma linha.

Travar o eixo

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' mantém o arraste no eixo vertical, 'x' no horizontal: as camadas e as abas acima usam isso. O axis do próprio item vence o da lista. Para manter o arraste também dentro da lista, adicione o modificador restrictToParent. Mais em restrições.

Estilizar a posição

A linha arrastada fica na lista como a posição em que vai pousar, e recebe data-dnd-placeholder. O estilo padrão a esmaece para 40%, com especificidade zero, então qualquer regra sua vence:

LayerRow.vue
.layer[data-dnd-placeholder] {
  opacity: 1;
  box-shadow: inset 0 0 0 1.5px #42b883;
}

.layer[data-dnd-placeholder] > * {
  visibility: hidden;   /* an empty slot of the row's exact size */
}

makeSortableItem retorna o mesmo estado como refs, e mais alguns:

RetornaO que é
isPlaceholderEsta linha é a posição em que um item arrastado vai pousar.
isDraggingEsta linha está sendo arrastada (todas as linhas arrastadas, em um multi-drag).
indexO índice dela no rascunho: muda enquanto você arrasta.
selectedGravável, ligado à seleção do provider.
indicatorNo modo indicador, onde a linha de inserção fica.

A lista retorna isOver e isAccepting para o seu próprio destaque, e activeKey e activeIndex, o item arrastado e o lugar dele no rascunho desta lista. Todos os estados, com demos ao vivo, estão em estilização.

Desligar coisas

OpçãoO que ela impede
disabled em um itemEste item não pode ser arrastado. Os outros ainda passam por ele: tranque uma camada acima, depois arraste outra por cima dela.
disabled na listaNenhum arraste sai dela, nenhum drop entra nela.
sort: false na listaOs itens dela não podem ser reordenados dentro dela, mas ainda podem sair: uma paleta, veja listas que compartilham itens.

Todas recebem um getter ou um ref, e valem para o próximo arraste:

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

Uma linha que precisa ficar em primeiro ou em último, não importa o que as outras façam, não é um item: renderize-a fora da lista. A camada Background do painel fica abaixo do <ul>.

Mostrar parte dos itens

Uma caixa de busca, uma tag: filter mostra os itens para os quais retorna true, e um arraste ordena entre eles. Os outros ficam no seu array, nos seus lugares:

Live

Carregando a demo…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Backlog from './Backlog.vue';
import type { Task } from './TaskRow.vue';

// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
  { id: 1, title: 'Login fails on Safari', tag: 'bug' },
  { id: 2, title: 'Dark mode for settings', tag: 'feature' },
  { id: 3, title: 'Document the export API', tag: 'docs' },
  { id: 4, title: 'Crash when the list is empty', tag: 'bug' },
  { id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
  { id: 6, title: 'Typo in the pricing page', tag: 'docs' },
  { id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
  { id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>

<template>
  <DnDProvider>
    <Backlog v-model="tasks" />
  </DnDProvider>
</template>
Backlog.vue
const query = ref('');
const tag = ref<Tag | null>(null);

const { items } = makeSortableList(list, {
  items: tasks,
  filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
  • items contém os itens que passam no filtro: renderize-o como sempre. O filtro é reativo: digitar na caixa de busca atualiza a lista na hora.
  • O drop escreve o array inteiro. Cada item oculto fica logo depois do item visível que ele seguia; quando esse é o que se moveu, ele fica depois do mais próximo antes dele que não se moveu.
  • e.to.index e o activeIndex da lista contam os itens visíveis. e.result contém o array inteiro, itens ocultos incluídos.
Um computed que filtra o seu array e é passado como items também funciona, mas é somente leitura: você aplicaria e.result por conta própria e calcularia para onde vão os itens ocultos. filter faz as duas coisas.

Uma linha de inserção

Com placeholder: 'indicator', a lista fica parada enquanto você arrasta, e uma linha mostra para onde vai o drop. Você desenha a linha:

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>

<template>
  <DnDProvider>
    <IssueTable />
  </DnDProvider>
</template>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>

<template>
  <div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>

<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
Campo de indicatorO que contém
listA lista em que o drop entra.
indexO índice de inserção na lista como ela está renderizada.
keyO item ao qual a linha está presa; null em uma lista vazia (nesse caso, desenhe-a na lista).
side'before' ou 'after' desse item; 'swap' em uma lista que troca de lugar. Árvores acrescentam 'inside'.
depth, parentKeySó em árvores: onde o drop pousa. Veja árvores.
  • O indicator da lista é definido enquanto a linha está naquela lista (a tabela o mostra), e o indicator de um item enquanto a linha está presa àquele item.
  • A linha arrastada fica onde estava, marcada como placeholder: esmaeça-a.
  • O teclado também move a linha de inserção.
  • Um drop fora de qualquer alvo cancela neste modo (dropOutside: 'cancel'), já que a lista nunca mostrou outra ordem.
  • O overlay cobre parte da linha embaixo dele. Deixe-o um pouco transparente, e a linha de inserção aparece através dele: [data-dnd-overlay] .row { opacity: 0.85 }.

Trocar de lugar em vez de deslocar

Com behavior: 'swap', o item arrastado troca de lugar com o que está embaixo dele, e os outros ficam onde estão:

Live

Carregando a demo…

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Rotation from './Rotation.vue';

const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
  {
    id: 'primary',
    title: 'Primary',
    people: [
      { id: 'ava', name: 'Ava', hue: 160 },
      { id: 'ben', name: 'Ben', hue: 20 },
      { id: 'cleo', name: 'Cleo', hue: 280 },
      { id: 'dev', name: 'Dev', hue: 210 },
      { id: 'eli', name: 'Eli', hue: 45 },
    ],
  },
  {
    id: 'backup',
    title: 'Backup',
    people: [
      { id: 'fay', name: 'Fay', hue: 330 },
      { id: 'gus', name: 'Gus', hue: 100 },
      { id: 'hana', name: 'Hana', hue: 190 },
      { id: 'ivo', name: 'Ivo', hue: 0 },
      { id: 'jun', name: 'Jun', hue: 250 },
    ],
  },
]);

/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>

<template>
  <DnDProvider>
    <div class="schedule">
      <div class="modes" role="group" aria-label="Swap preview">
        <button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
        <button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
      </div>
      <div class="week">
        <ol class="days">
          <li v-for="day in DAYS" :key="day">{{ day }}</li>
        </ol>
        <Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
      </div>
      <p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.schedule {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  display: flex;
  padding: 3px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes button {
  padding: 6px 12px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  opacity: 0.6;
  cursor: pointer;
}

.modes button[aria-pressed='true'] {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  opacity: 1;
}

.week {
  display: flex;
  gap: 10px;
  width: 400px;
  max-width: 100%;
}

.days {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 21px 0 0;
  padding: 0;
  list-style: none;
}

.days li {
  height: 36px;
  font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.last {
  margin: 0;
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>
Rotation.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'people'),
  group: 'rotation',
  behavior: 'swap',
  placeholder: () => props.placeholder,   // 'item' (live) or 'indicator' (on drop)
  onDrop: (e) => {
    if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
  },
});
  • Ao vivo (placeholder: 'item', padrão): a troca aparece enquanto você arrasta. Voltar, ou sair da lista, a desfaz.
  • No drop (placeholder: 'indicator'): nada se move até o drop. O indicador marca o item com o qual o drop troca, side: 'swap': contorne esse item em vez de desenhar uma linha. As setas movem a marca de item em item; de volta ao item arrastado, não há marca.
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

O evento de drop tem mode: 'swap' e partner, o item com o qual houve a troca: a key dele, o item, e o from e o to dele. Duas listas de um mesmo grupo trocam entre si quando ambas têm behavior: 'swap', como as duas colunas. O item sob o ponteiro então vai para a outra lista, então ele precisa ter permissão para sair (group.pull) e para entrar (group.put): veja listas que compartilham itens. Um multi-drag ordena em vez de trocar.

Teclado

Foque uma linha, ou o handle dela, e pressione Space ou Enter para pegá-la. As setas ao longo da lista a movem uma posição; em grades, para cima e para baixo vão para a fileira seguinte, e as setas no sentido transversal à lista a levam para a próxima lista do grupo. Depois do fim de uma lista, a seta segue para a próxima lista naquela direção (listas empilhadas uma acima da outra) ou, se não houver, para um alvo de drop ali, como uma lixeira. Pressione Space ou Enter de novo para soltar, Escape para devolvê-la. Todas as demos desta página funcionam assim. Os textos para leitores de tela estão em acessibilidade.

Animações

As linhas deslizam para os novos lugares enquanto você arrasta, e a linha solta pousa na sua posição. A própria lista cresce ou encolhe até o novo tamanho quando linhas entram e saem (animateSize). Isso vem ligado por padrão e não precisa de código. Não envolva as linhas em <TransitionGroup>: os movimentos seriam animados duas vezes. Também não coloque um transition CSS no transform das linhas. Para mudanças que nenhum arraste fez (adicionar, remover, embaralhar), ligue a opção animateChanges da lista, como as abas fazem. Veja animações.

A lista completa de opções está na API do makeSortableList e do makeSortableItem.