Preview Updated 2026-05-10

Sortable 排序列表

通用拖拽重排序容器。Pointer Events + setPointerCapture 实现,跨桌面/移动端一致;列表、网格、横向 chip 行都可用。

English translation pending This page hasn't been translated yet — falling back to Chinese. PRs welcome on GitHub.

基础用法

items 数组 + itemKey(取唯一 id),通过默认插槽渲染每一项。拖动项目时邻居自动让位,松手提交新顺序。组件会 emit('update:items', next),所以可以直接用 v-model:items 双向绑定。

背景 视口
src/App.vue
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

interface Item {
  id: string;
  label: string;
  hint: string;
}

const items = ref<Item[]>([
  { id: 'a', label: '需求分析', hint: 'PM 输入' },
  { id: 'b', label: '设计稿评审', hint: 'Design' },
  { id: 'c', label: '前后端拉齐', hint: 'Eng' },
  { id: 'd', label: '联调', hint: 'QA' },
  { id: 'e', label: '上线灰度', hint: 'SRE' },
]);
</script>
<template>
  <div class="sortable-demo">
    <CfSortable v-model:items="items" item-key="id">
      <template #default="{ item, isDragging }">
        <div class="sortable-row" :class="{ 'is-active': isDragging }">
          <span class="sortable-row__index" />
          <div class="sortable-row__body">
            <strong>{{ item.label }}</strong>
            <span>{{ item.hint }}</span>
          </div>
        </div>
      </template>
    </CfSortable>
    <pre class="sortable-demo__state">{{ items.map((x) => x.id).join(' → ') }}</pre>
  </div>
</template>
<style scoped>
.sortable-demo {
  display: grid;
  gap: 10px;
}
.sortable-row {
  display: grid;
  grid-template-columns: 8px 1fr;
  gap: 10px;
  align-items: center;
  padding: 10px 12px;
  background: var(--bg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-3);
}
.sortable-row.is-active {
  border-color: var(--accent-1);
  background: var(--bg-2);
}
.sortable-row__index {
  width: 4px;
  height: 22px;
  background: var(--accent-1);
  border-radius: 2px;
}
.sortable-row__body {
  display: flex;
  align-items: baseline;
  gap: 10px;
  color: var(--fg-1);
}
.sortable-row__body span {
  color: var(--fg-3);
  font-size: var(--t-12);
}
.sortable-demo__state {
  margin: 0;
  padding: 8px 10px;
  background: var(--bg-inset);
  border-radius: var(--r-3);
  font-size: var(--t-12);
  color: var(--fg-2);
}
</style>
<script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

const items = ref<Item[]>([
  { id: 'a', label: '需求分析', hint: 'PM 输入' },
  { id: 'b', label: '设计稿评审', hint: 'Design' },
  { id: 'c', label: '前后端拉齐', hint: 'Eng' },
  { id: 'd', label: '联调', hint: 'QA' },
  { id: 'e', label: '上线灰度', hint: 'SRE' },
]);
</script>
<template>
  <div class="sortable-demo">
    <CfSortable v-model:items="items" item-key="id">
      <template #default="{ item, isDragging }">
        <div class="sortable-row" :class="{ 'is-active': isDragging }">
          <span class="sortable-row__index" />
          <div class="sortable-row__body">
            <strong>{{ item.label }}</strong>
            <span>{{ item.hint }}</span>
          </div>
        </div>
      </template>
    </CfSortable>
    <pre class="sortable-demo__state">{{ items.map((x) => x.id).join(' → ') }}</pre>
  </div>
</template>
<style scoped>
.sortable-demo {
  display: grid;
  gap: 10px;
}
.sortable-row {
  display: grid;
  grid-template-columns: 8px 1fr;
  gap: 10px;
  align-items: center;
  padding: 10px 12px;
  background: var(--bg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-3);
}
.sortable-row.is-active {
  border-color: var(--accent-1);
  background: var(--bg-2);
}
.sortable-row__index {
  width: 4px;
  height: 22px;
  background: var(--accent-1);
  border-radius: 2px;
}
.sortable-row__body {
  display: flex;
  align-items: baseline;
  gap: 10px;
  color: var(--fg-1);
}
.sortable-row__body span {
  color: var(--fg-3);
  font-size: var(--t-12);
}
.sortable-demo__state {
  margin: 0;
  padding: 8px 10px;
  background: var(--bg-inset);
  border-radius: var(--r-3);
  font-size: var(--t-12);
  color: var(--fg-2);
}
</style>
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {
  interface Item {
    id: string;
    label: string;
    hint: string;
  }

  const [items, setItems] = useState<Item[]>([
    { id: 'a', label: '需求分析', hint: 'PM 输入' },
    { id: 'b', label: '设计稿评审', hint: 'Design' },
    { id: 'c', label: '前后端拉齐', hint: 'Eng' },
    { id: 'd', label: '联调', hint: 'QA' },
    { id: 'e', label: '上线灰度', hint: 'SRE' },
  ]);
  return (
    <>
      <div className="sortable-demo">
          <CfSortable items={items} onItemsChange={setItems} item-key="id">
              <div className="sortable-row" className={{ 'is-active': isDragging }}>
                <span className="sortable-row__index" />
                <div className="sortable-row__body">
                  <strong>{item.label}</strong>
                  <span>{item.hint}</span>
                </div>
              </div>
    </>
  );
}
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {

  const [items, setItems] = useState<Item[]>([
    { id: 'a', label: '需求分析', hint: 'PM 输入' },
    { id: 'b', label: '设计稿评审', hint: 'Design' },
    { id: 'c', label: '前后端拉齐', hint: 'Eng' },
    { id: 'd', label: '联调', hint: 'QA' },
    { id: 'e', label: '上线灰度', hint: 'SRE' },
  ]);
  return (
    <>
      <div className="sortable-demo">
          <CfSortable items={items} onItemsChange={setItems} item-key="id">
              <div className="sortable-row" className={{ 'is-active': isDragging }}>
                <span className="sortable-row__index" />
                <div className="sortable-row__body">
                  <strong>{item.label}</strong>
                  <span>{item.hint}</span>
                </div>
              </div>
    </>
  );
}

拖拽手柄

handle 选择器,只有命中手柄的 pointerdown 才会启动拖拽。其余区域可正常点击 / 选中。

背景 视口
src/App.vue
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

interface Track {
  id: string;
  title: string;
  artist: string;
}

const tracks = ref<Track[]>([
  { id: '1', title: 'Northbound', artist: 'Riff Cohen' },
  { id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
  { id: '3', title: 'Pegasus', artist: 'Inferno' },
  { id: '4', title: 'Cape', artist: 'Tycho' },
  { id: '5', title: 'Halftime', artist: 'Nas' },
]);
</script>
<template>
  <CfSortable v-model:items="tracks" item-key="id" handle=".track-grip">
    <template #default="{ item }">
      <div class="track-row">
        <button type="button" class="track-grip" aria-label="drag">
          <svg viewBox="0 0 16 16" width="14" height="14">
            <circle cx="6" cy="4" r="1.2" fill="currentColor" />
            <circle cx="10" cy="4" r="1.2" fill="currentColor" />
            <circle cx="6" cy="8" r="1.2" fill="currentColor" />
            <circle cx="10" cy="8" r="1.2" fill="currentColor" />
            <circle cx="6" cy="12" r="1.2" fill="currentColor" />
            <circle cx="10" cy="12" r="1.2" fill="currentColor" />
          </svg>
        </button>
        <div class="track-meta">
          <strong>{{ item.title }}</strong>
          <span>{{ item.artist }}</span>
        </div>
      </div>
    </template>
  </CfSortable>
</template>
<style scoped>
.track-row {
  display: grid;
  grid-template-columns: 32px 1fr;
  align-items: center;
  gap: 12px;
  padding: 8px 12px;
  background: var(--bg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-3);
  cursor: default;
}
.track-grip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
  color: var(--fg-3);
  border-radius: var(--r-2);
  cursor: grab;
  touch-action: none;
}
.track-grip:hover {
  color: var(--fg-1);
  background: var(--bg-2);
}
.track-meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.track-meta strong {
  color: var(--fg-1);
  font-size: var(--t-13);
}
.track-meta span {
  color: var(--fg-3);
  font-size: var(--t-12);
}
</style>
<script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

const tracks = ref<Track[]>([
  { id: '1', title: 'Northbound', artist: 'Riff Cohen' },
  { id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
  { id: '3', title: 'Pegasus', artist: 'Inferno' },
  { id: '4', title: 'Cape', artist: 'Tycho' },
  { id: '5', title: 'Halftime', artist: 'Nas' },
]);
</script>
<template>
  <CfSortable v-model:items="tracks" item-key="id" handle=".track-grip">
    <template #default="{ item }">
      <div class="track-row">
        <button type="button" class="track-grip" aria-label="drag">
          <svg viewBox="0 0 16 16" width="14" height="14">
            <circle cx="6" cy="4" r="1.2" fill="currentColor" />
            <circle cx="10" cy="4" r="1.2" fill="currentColor" />
            <circle cx="6" cy="8" r="1.2" fill="currentColor" />
            <circle cx="10" cy="8" r="1.2" fill="currentColor" />
            <circle cx="6" cy="12" r="1.2" fill="currentColor" />
            <circle cx="10" cy="12" r="1.2" fill="currentColor" />
          </svg>
        </button>
        <div class="track-meta">
          <strong>{{ item.title }}</strong>
          <span>{{ item.artist }}</span>
        </div>
      </div>
    </template>
  </CfSortable>
</template>
<style scoped>
.track-row {
  display: grid;
  grid-template-columns: 32px 1fr;
  align-items: center;
  gap: 12px;
  padding: 8px 12px;
  background: var(--bg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-3);
  cursor: default;
}
.track-grip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border: 0;
  background: transparent;
  color: var(--fg-3);
  border-radius: var(--r-2);
  cursor: grab;
  touch-action: none;
}
.track-grip:hover {
  color: var(--fg-1);
  background: var(--bg-2);
}
.track-meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.track-meta strong {
  color: var(--fg-1);
  font-size: var(--t-13);
}
.track-meta span {
  color: var(--fg-3);
  font-size: var(--t-12);
}
</style>
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {
  interface Track {
    id: string;
    title: string;
    artist: string;
  }

  const [tracks, setTracks] = useState<Track[]>([
    { id: '1', title: 'Northbound', artist: 'Riff Cohen' },
    { id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
    { id: '3', title: 'Pegasus', artist: 'Inferno' },
    { id: '4', title: 'Cape', artist: 'Tycho' },
    { id: '5', title: 'Halftime', artist: 'Nas' },
  ]);
  return (
    <>
      <CfSortable items={tracks} onItemsChange={setTracks} item-key="id" handle=".track-grip">
            <div className="track-row">
              <button type="button" className="track-grip" aria-label="drag">
                <svg viewBox="0 0 16 16" width="14" height="14">
                  <circle cx="6" cy="4" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="4" r="1.2" fill="currentColor" />
                  <circle cx="6" cy="8" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="8" r="1.2" fill="currentColor" />
                  <circle cx="6" cy="12" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="12" r="1.2" fill="currentColor" />
                </svg>
              </button>
              <div className="track-meta">
                <strong>{item.title}</strong>
                <span>{item.artist}</span>
              </div>
            </div>
    </>
  );
}
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {

  const [tracks, setTracks] = useState<Track[]>([
    { id: '1', title: 'Northbound', artist: 'Riff Cohen' },
    { id: '2', title: 'Pyramids', artist: 'Frank Ocean' },
    { id: '3', title: 'Pegasus', artist: 'Inferno' },
    { id: '4', title: 'Cape', artist: 'Tycho' },
    { id: '5', title: 'Halftime', artist: 'Nas' },
  ]);
  return (
    <>
      <CfSortable items={tracks} onItemsChange={setTracks} item-key="id" handle=".track-grip">
            <div className="track-row">
              <button type="button" className="track-grip" aria-label="drag">
                <svg viewBox="0 0 16 16" width="14" height="14">
                  <circle cx="6" cy="4" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="4" r="1.2" fill="currentColor" />
                  <circle cx="6" cy="8" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="8" r="1.2" fill="currentColor" />
                  <circle cx="6" cy="12" r="1.2" fill="currentColor" />
                  <circle cx="10" cy="12" r="1.2" fill="currentColor" />
                </svg>
              </button>
              <div className="track-meta">
                <strong>{item.title}</strong>
                <span>{item.artist}</span>
              </div>
            </div>
    </>
  );
}

横向轴

axis="x" 让排序方向变为水平。Chip 行、Tab 行、画廊缩略图常用。

背景 视口
src/App.vue
<script setup lang="ts">
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

interface Tab {
  id: string;
  label: string;
}

const tabs = ref<Tab[]>([
  { id: 'overview', label: '总览' },
  { id: 'logs', label: '日志' },
  { id: 'metrics', label: '指标' },
  { id: 'traces', label: '链路' },
  { id: 'errors', label: '错误' },
]);
</script>
<template>
  <CfSortable v-model:items="tabs" item-key="id" axis="x">
    <template #default="{ item }">
      <div class="tab-chip">{{ item.label }}</div>
    </template>
  </CfSortable>
</template>
<style scoped>
.tab-chip {
  display: inline-flex;
  align-items: center;
  padding: 6px 14px;
  margin-right: 6px;
  background: var(--bg-2);
  color: var(--fg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-pill);
  font-size: var(--t-13);
  white-space: nowrap;
}
</style>
<script setup>
import { ref } from 'vue';
import { CfSortable } from '@chufix-design/vue';

const tabs = ref<Tab[]>([
  { id: 'overview', label: '总览' },
  { id: 'logs', label: '日志' },
  { id: 'metrics', label: '指标' },
  { id: 'traces', label: '链路' },
  { id: 'errors', label: '错误' },
]);
</script>
<template>
  <CfSortable v-model:items="tabs" item-key="id" axis="x">
    <template #default="{ item }">
      <div class="tab-chip">{{ item.label }}</div>
    </template>
  </CfSortable>
</template>
<style scoped>
.tab-chip {
  display: inline-flex;
  align-items: center;
  padding: 6px 14px;
  margin-right: 6px;
  background: var(--bg-2);
  color: var(--fg-1);
  border: 1px solid var(--line-1);
  border-radius: var(--r-pill);
  font-size: var(--t-13);
  white-space: nowrap;
}
</style>
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {
  interface Tab {
    id: string;
    label: string;
  }

  const [tabs, setTabs] = useState<Tab[]>([
    { id: 'overview', label: '总览' },
    { id: 'logs', label: '日志' },
    { id: 'metrics', label: '指标' },
    { id: 'traces', label: '链路' },
    { id: 'errors', label: '错误' },
  ]);
  return (
    <>
      <CfSortable items={tabs} onItemsChange={setTabs} item-key="id" axis="x">
            <div className="tab-chip">{item.label}</div>
    </>
  );
}
import { useState } from 'react';
import { CfSortable } from '@chufix-design/react';

export default function Demo() {

  const [tabs, setTabs] = useState<Tab[]>([
    { id: 'overview', label: '总览' },
    { id: 'logs', label: '日志' },
    { id: 'metrics', label: '指标' },
    { id: 'traces', label: '链路' },
    { id: 'errors', label: '错误' },
  ]);
  return (
    <>
      <CfSortable items={tabs} onItemsChange={setTabs} item-key="id" axis="x">
            <div className="tab-chip">{item.label}</div>
    </>
  );
}

API

Props

属性类型默认说明
itemsT[]数据数组(必传)
itemKeykeyof T | ((item, index) => string | number)唯一 key
axis'x' | 'y''y'排序轴
handlestring拖拽手柄 CSS 选择器;不传则整行可拖
disabledbooleanfalse禁用拖拽
thresholdnumber4起拖位移阈值 (px)
animationnumber180邻居复位过渡时长 (ms)
tagstring'div'根标签

Events

事件载荷说明
update:itemsT[]新顺序,配合 v-model:items
reorder{ from, to, items }提交后触发
drag-start{ item, index }越过 threshold 时
drag-end{ item, index, cancelled }松手 / Esc 取消

默认插槽

{ item, index, isDragging } — 渲染单条数据;isDragging 用于显示当前拖动项的悬浮态。

反馈与讨论

Sortable 排序列表 · Discussion

0
0 / 600
正在加载评论...