- Angular
Angular ドラッグ&ドロップ|CDKで並び替え・リスト間移動・保存
Angular でドラッグ&ドロップを作るなら、公式の部品集 Angular CDK の @angular/cdk/drag-drop を使います。 並べる親に cdkDropList、動かす要素に cdkDrag を付け、離したとき(cdkDropListDropped)に moveItemInArray で配列を並べ替えます。
import { Component, signal } from '@angular/core';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-sortable',
imports: [CdkDropList, CdkDrag],
template: `
<ul cdkDropList (cdkDropListDropped)="drop($event)">
@for (item of items(); track item) {
<li cdkDrag>{{ item }}</li>
}
</ul>`,
})
export class Sortable {
items = signal(['要件を聞く', '画面設計', 'API設計']);
drop(e: CdkDragDrop<string[]>) {
const next = [...this.items()]; // コピーしてから
moveItemInArray(next, e.previousIndex, e.currentIndex);
this.items.set(next); // 新しい配列を set
}
}
大事なのは、CDK は見た目を動かすだけで、データ(配列)は変えないことです。 angular.dev のガイドにも、ドラッグ&ドロップのディレクティブはデータモデルを更新しない、とあります。並べ替えた結果を残すのは、drop の中の自分のコードです。
確認環境:Angular 22.2.2・@angular/cdk 22.2.2、Node.js 24.15.0(Linux)。ng new のプロジェクトにカンバンを作り、開発ビルドを Chromium(Playwright 1.56.1)で開いて、マウスで実際にドラッグしました。保存の通信は Playwright で受けて中身を記録しました(確認日 2026年10月12日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular ドラッグ&ドロップ|CDKで並び替え・リスト間移動・保存」
導入
ng add @angular/cdk
# または
npm install @angular/cdk
@angular/cdk の版は、@angular/core と同じメジャー版(v22 なら 22.x)にそろえます。standalone のコンポーネントでは、使うディレクティブを imports に1つずつ書きます(DragDropModule をまとめて入れる書き方もあります)。
よく使う API 早見表
| 名前 | 付ける場所 | 役割 |
|---|---|---|
cdkDropList |
並べる親 | ドロップできる一覧にする |
cdkDrag |
動かす要素 | ドラッグできるようにする |
cdkDropListGroup |
複数の一覧を囲む要素 | 中の一覧どうしを自動でつなぐ |
[cdkDropListConnectedTo] |
cdkDropList |
つなぐ一覧を個別に指定(id か参照) |
[cdkDropListData] / [cdkDragData] |
一覧 / 要素 | イベントで受け取るデータ |
cdkDragHandle |
cdkDrag の中の要素 |
ここをつかんだときだけドラッグ |
*cdkDragPreview |
cdkDrag の中 |
ドラッグ中に指についてくる見た目 |
*cdkDragPlaceholder |
cdkDrag の中 |
落とす位置に出る目印 |
cdkDragBoundary |
cdkDrag |
ドラッグできる範囲(CSS セレクター) |
cdkDragLockAxis / cdkDropListLockAxis |
要素 / 一覧 | x か y の方向だけに動かす |
[cdkDropListEnterPredicate] |
cdkDropList |
この一覧に入れてよいかを決める関数 |
cdkDropListOrientation |
cdkDropList |
horizontal(横並び)・mixed(折り返し) |
cdkDragStartDelay |
cdkDrag |
押してから動き出すまでの待ち時間(ミリ秒) |
(cdkDropListDropped) で受け取る CdkDragDrop には、previousIndex(元の位置)・currentIndex(落とした位置)・previousContainer(元の一覧)・container(落とした一覧)・item などが入っています(@angular/cdk 22.2.2 の型定義で確認)。
配列を動かす3つの関数(実行結果)
| 関数 | 実行したコード | 結果 |
|---|---|---|
moveItemInArray |
a = ['A','B','C','D']; moveItemInArray(a, 0, 2) |
["B","C","A","D"] |
moveItemInArray(範囲外) |
z = ['A','B','C']; moveItemInArray(z, 0, 10) |
["B","C","A"](末尾に入る) |
transferArrayItem |
s = ['A','B']; t = ['X','Y']; transferArrayItem(s, t, 1, 0) |
s = ["A"]、t = ["B","X","Y"] |
copyArrayItem |
s = ['A','B']; t = ['X']; copyArrayItem(s, t, 0, 1) |
s = ["A","B"]、t = ["X","A"] |
3つとも、渡した配列そのものを書き換えます(戻り値はありません)。 Signal で持つ配列に直接使うときは、次の注意があります。
Signal の配列を直接書き換えると、computed が更新されなかった
items = signal(['A', 'B', 'C']);
first = computed(() => this.items()[0]);
// ① 直接書き換え
moveItemInArray(this.items(), 0, 2);
// ② コピーしてから set
const next = [...this.items()];
moveItemInArray(next, 0, 2);
this.items.set(next);
| 書き方 | 一覧の表示 | first() の表示 |
|---|---|---|
| ① 直接書き換え | B C A | A(前の値のまま) |
② コピーしてから set |
B C A | B |
①でも一覧は並び替わって見えましたが、これはボタンのイベントで画面が描き直されたためです。Signal 自体には「変わった」が伝わっていないので、computed() や effect() は動きませんでした。 Signal の配列は、コピーしてから set します。Signal の扱いは、関連記事「Angular Signals使い方|v22対応の全API早見表と実行結果つきコード」で扱っています。
完成コード:カンバン(リスト間移動・WIP 上限・保存)
「未着手」「作業中」「完了」の3列を、カードのドラッグで移動するカンバンです。
- 列どうしは
cdkDropListGroupでつなぐ - つかむ場所はハンドル(⠿)だけにする
- 「作業中」は3件まで(
cdkDropListEnterPredicate) - ドロップしたら、先に画面を更新してから API に保存し、失敗したら元に戻す
// kanban.ts
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
CdkDrag, CdkDragDrop, CdkDragHandle, CdkDragPlaceholder, CdkDragPreview,
CdkDropList, CdkDropListGroup, moveItemInArray, transferArrayItem,
} from '@angular/cdk/drag-drop';
export interface Task { id: number; title: string; }
export interface Column { id: 'todo' | 'doing' | 'done'; name: string; tasks: Task[]; }
const WIP_LIMIT = 3; // 「作業中」に置ける上限
@Component({
selector: 'app-kanban',
imports: [CdkDropListGroup, CdkDropList, CdkDrag, CdkDragHandle, CdkDragPreview, CdkDragPlaceholder],
template: `
<div class="board" cdkDropListGroup>
@for (col of columns(); track col.id) {
<section class="column">
<h2>{{ col.name }} ({{ col.tasks.length }})</h2>
<div class="list" cdkDropList [id]="col.id" [cdkDropListData]="col.tasks"
[cdkDropListEnterPredicate]="canEnter" (cdkDropListDropped)="drop($event)">
@for (task of col.tasks; track task.id) {
<div class="card" cdkDrag [cdkDragData]="task" cdkDragBoundary=".board" [attr.data-id]="task.id">
<span class="handle" cdkDragHandle aria-label="ドラッグして移動">⠿</span>
{{ task.title }}
<div class="card preview" *cdkDragPreview>{{ task.title }}</div>
<div class="placeholder" *cdkDragPlaceholder></div>
</div>
}
</div>
</section>
}
</div>
<p class="status">{{ status() }}</p>`,
styles: `
.board { display: flex; gap: 12px; padding: 8px; }
.column { flex: 1; background: #f4f5f7; border-radius: 8px; padding: 8px; }
.list { min-height: 60px; }
.card { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: 8px; margin-bottom: 6px; }
.handle { cursor: move; margin-right: 6px; color: #888; }
/* ドラッグ中、指についてくる要素 */
.cdk-drag-preview { box-shadow: 0 8px 16px rgba(0,0,0,.2); transform: rotate(2deg); }
/* 落とす位置の目印 */
.placeholder { height: 36px; margin-bottom: 6px; border: 2px dashed #6b8afd; border-radius: 6px; background: #eef2ff; }
/* 離したあと、所定の位置へ動くアニメーション */
.cdk-drag-animating { transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); }
/* 一覧のほかのカードがよけるアニメーション */
.list.cdk-drop-list-dragging .card:not(.cdk-drag-placeholder) { transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); }
/* 受け入れできる一覧を目立たせる */
.list.cdk-drop-list-receiving { outline: 2px dashed #c3cff8; }
`,
})
export class Kanban {
private http = inject(HttpClient);
readonly status = signal('');
readonly columns = signal<Column[]>([
{ id: 'todo', name: '未着手', tasks: [{ id: 1, title: '要件を聞く' }, { id: 2, title: '画面設計' }, { id: 3, title: 'API設計' }] },
{ id: 'doing', name: '作業中', tasks: [{ id: 4, title: 'ログイン画面' }] },
{ id: 'done', name: '完了', tasks: [{ id: 5, title: '環境構築' }] },
]);
// 「作業中」は WIP_LIMIT 件まで
canEnter = (drag: CdkDrag<Task>, drop: CdkDropList<Task[]>) =>
drop.id !== 'doing' || drop.data.length < WIP_LIMIT;
drop(e: CdkDragDrop<Task[]>) {
if (e.previousContainer === e.container && e.previousIndex === e.currentIndex) return;
const before = structuredClone(this.columns());
const next = structuredClone(before);
const from = next.find(c => c.id === e.previousContainer.id)!;
const to = next.find(c => c.id === e.container.id)!;
if (from === to) {
moveItemInArray(to.tasks, e.previousIndex, e.currentIndex);
} else {
transferArrayItem(from.tasks, to.tasks, e.previousIndex, e.currentIndex);
}
this.columns.set(next); // 先に画面を更新(楽観的更新)
this.save(next, before);
}
private save(next: Column[], before: Column[]) {
const body = next.map(c => ({ column: c.id, taskIds: c.tasks.map(t => t.id) }));
this.status.set('保存中…');
this.http.put('/api/board/order', body).subscribe({
next: () => this.status.set('保存しました'),
error: () => { this.columns.set(before); this.status.set('保存に失敗したので元に戻しました'); },
});
}
}
cdkDropList の [id] に列の id(todo など)を入れているので、e.container.id でどの列に落としたかが分かります。v22 では HttpClient は provideHttpClient() を書かなくても注入できます(angular.dev のセットアップの説明では v21 から)。HttpClient の使い方は関連記事「Angular HttpClientの使い方|GET・POSTとエラー処理」で扱っています。
Chromium で実際にドラッグした結果
各カードの data-id で、列ごとの並びを記録しました。
| 操作 | 未着手 | 作業中 | 完了 | 表示 |
|---|---|---|---|---|
| 最初 | 1, 2, 3 | 4 | 5 | |
| カード1を、カード3の下へ | 2, 3, 1 | 4 | 5 | 保存しました |
| カード2を「作業中」へ | 3, 1 | 2, 4 | 5 | 保存しました |
| カード3を「作業中」へ | 1 | 3, 2, 4 | 5 | 保存しました |
| カード1を「作業中」へ(4件目) | 1 | 3, 2, 4 | 5 | 入らなかった(上限3) |
| カード5を「未着手」へ(API が 500) | 1 | 3, 2, 4 | 5 | 保存に失敗したので元に戻しました |
「作業中」が3件になると、4件目は入れられず、カードは元の列に戻りました。 保存に失敗したときは、一度「未着手」に移ったカード5が「完了」に戻りました。
API に送られた中身は次のとおりです(失敗したときの分を含めて4回)。
PUT /api/board/order [{"column":"todo","taskIds":[2,3,1]},{"column":"doing","taskIds":[4]},{"column":"done","taskIds":[5]}]
PUT /api/board/order [{"column":"todo","taskIds":[3,1]},{"column":"doing","taskIds":[2,4]},{"column":"done","taskIds":[5]}]
PUT /api/board/order [{"column":"todo","taskIds":[1]},{"column":"doing","taskIds":[3,2,4]},{"column":"done","taskIds":[5]}]
PUT /api/board/order [{"column":"todo","taskIds":[1,5]},{"column":"doing","taskIds":[3,2,4]},{"column":"done","taskIds":[]}]
並び順を API に保存するときの考え方
| 送り方 | 中身の例 | 向いている場面 |
|---|---|---|
| 全体の並びを送る(上の例) | 列ごとの ID の並び | 件数が少ない。サーバー側は丸ごと置き換え |
| 動いた1件だけ送る | { taskId: 1, column: 'doing', index: 0 } |
件数が多い。サーバー側で位置をずらす |
| 並び順の番号を持たせる | { taskId: 1, sortOrder: 3 } を変わった分だけ |
DB に sort_order 列がある |
先に画面を更新し、失敗したら元に戻す形(楽観的更新)にすると、ドロップしてから画面が動くまでの待ちがなくなります。 元に戻すために、ドロップ前の状態(before)を structuredClone でとっておきます。ドラッグを続けて行うと保存が重なるので、件数が多い画面では、送る前に少し待ってまとめる(RxJS の debounceTime など)方法もあります。
プレビュー・プレースホルダーの CSS
CDK は、ドラッグの状態に合わせて次のクラスを付けます(angular.dev のガイドより)。
| クラス | 付く要素 | 使い道 |
|---|---|---|
.cdk-drag-preview |
ドラッグ中、ポインターについてくる要素 | 影・傾きを付ける |
.cdk-drag-placeholder |
落とす位置に置かれる要素 | 点線の枠にする |
.cdk-drag-animating |
離したあと、位置に戻るまでの要素 | transition で動きを付ける |
.cdk-drop-list-dragging |
ドラッグ中の要素がいる一覧 | ほかの要素に transition を付ける |
.cdk-drop-list-receiving |
受け入れできる、つながった一覧 | 枠を出して「ここに置ける」と示す |
上のカンバンを開いてドラッグ中の画面を撮ると、ポインターの位置に影付きのカード(プレビュー)、「作業中」の列に点線の枠(プレースホルダー)、ほかの列に薄い点線の枠(.cdk-drop-list-receiving)が出ていました。
プレビューは body の直下に置かれていました(Playwright で親要素を調べると BODY、popover="manual" 付き)。プレースホルダーは一覧(.list)の中でした。今回は、コンポーネントの styles に書いた .cdk-drag-preview の影(box-shadow)が、body の下のプレビューにも効いていました。置き場所を変えたいときは cdkDragPreviewContainer で parent などを指定します。
ドラッグできる範囲を制限する
| したいこと | 書き方 |
|---|---|
| ボードの外に出さない | cdkDrag に cdkDragBoundary=".board" |
| 縦にだけ動かす | cdkDragLockAxis="y"(一覧全体なら cdkDropListLockAxis="y") |
| ハンドルをつかんだときだけ動かす | 中の要素に cdkDragHandle |
| 特定の要素を動かせなくする | [cdkDragDisabled]="task.locked" |
| 一覧の中で並び替えさせない | cdkDropListSortingDisabled |
| 入れてよい一覧を絞る | [cdkDropListEnterPredicate]="関数" |
cdkDragBoundary=".board" を付けたカードを、ボードの右下の外までドラッグしたとき、プレビューの右端・下端はボードの右端・下端と同じ位置(892px・247px)で止まりました。
Playwright でドラッグを自動テストする
CDK は、押してから既定で5px動くまでドラッグを始めません(@angular/cdk 22.2.2 の dragStartThreshold の既定値)。Playwright では、マウスを押したあと、少しずつ動かします。
async function drag(page: Page, fromSelector: string, toSelector: string) {
const from = (await page.locator(fromSelector).boundingBox())!;
const to = (await page.locator(toSelector).boundingBox())!;
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x + 10, from.y + 10, { steps: 5 }); // 動き始め
await page.mouse.move(to.x + to.width / 2, to.y + to.height - 4, { steps: 20 });
await page.mouse.up();
}
await drag(page, '.card[data-id="1"] .handle', '.card[data-id="3"]');
この関数で、上の表の結果を得ました。
よくある質問
Q. 横に並んだ一覧で並び替えたいです。
A. cdkDropListOrientation="horizontal" を付けます。折り返して複数行になる一覧は mixed です(angular.dev のガイドより)。
Q. 元の一覧に残したまま、別の一覧にコピーしたいです。
A. transferArrayItem の代わりに copyArrayItem を使います。上の実行結果のとおり、元の配列は変わりません。ドラッグ中も元の場所に要素を残すなら cdkDropListHasAnchor を使います(angular.dev のガイドより)。
Q. スマホで画面をスクロールしようとするとドラッグになってしまいます。
A. cdkDragStartDelay で、押してから動き出すまでの時間(ミリ秒)を空けられます(angular.dev のガイドより)。ハンドル(cdkDragHandle)をつかんだときだけ動かす形にすると、ほかの場所はスクロールに使えます。
まとめ
@angular/cdk/drag-dropのcdkDropList・cdkDragで作る。データは自分で更新する- 並び替えは
moveItemInArray、リスト間はtransferArrayItem、コピーはcopyArrayItem - Signal の配列は コピーしてから
set。直接書き換えるとcomputedが更新されなかった - 列の上限は
cdkDropListEnterPredicate、範囲はcdkDragBoundary - 保存は 先に画面を更新し、失敗したら元に戻す。Chromium で実際にドラッグして、送られた内容まで確かめた
参考・出典
確認日はいずれも 2026年10月12日です。
- Angular「Drag and drop」:https://angular.dev/guide/drag-drop
- Angular(日本語版)「ドラッグ&ドロップ」:https://angular.jp/guide/drag-drop
- Angular API「CdkDropList」:https://angular.dev/api/cdk/drag-drop/CdkDropList
- Angular「Setting up HttpClient」:https://angular.dev/guide/http/setup
- Infragistics「Angular Drag and Drop (ドラッグアンドドロップ) ディレクティブの概要」:https://jp.infragistics.com/products/ignite-ui-angular/angular/components/drag-drop
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular ドラッグ&ドロップ|CDKで並び替え・リスト間移動・保存」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。