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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?