@m1z23r/ngx-dnd
v0.0.1
Published
A signals-first Angular drag and drop library - sortable lists, connected and nested drop lists, drop zones, copy sources, auto-scroll and custom previews. No rxjs, no CDK.
Maintainers
Readme
@m1z23r/ngx-dnd
A signals-first Angular drag and drop library - sortable lists, connected and nested drop lists, drop zones and copy sources, built to carry a kanban board. No rxjs, no CDK, no third-party engine.
Everything reactive is signal() and computed(). Lists are connected and nested through a plain string group, so a card can drag inside its column, across columns, and a column can drag across the whole board, all with the same primitives. Sorting never touches DOM order - siblings slide with translate3d while dragging and the real array move happens once, on drop, through a model() write-back. Geometry is scale-correct, so the whole thing works unmodified inside a transform: scale() ancestor.
Features
- Sortable lists via
[ngxDropList]+[(items)]- reorder, drop is written back for you - Connected and nested lists joined by a named
group- cards inside columns, columns on a board - Drag handles, disabled items, disabled lists,
sortingDisabledlists that only accept transfers canEnterandcanSortpredicates for WIP limits, type gates and pinned items- Non-sorting
[ngxDropZone]targets for trash/archive drops copydrag mode for palettes and template trays that leave the source untouched- Custom preview and placeholder templates, or working clones by default
- Edge auto-scroll, both axes, independent per scrollable ancestor
- Correct hit-testing, gap position and preview placement inside CSS-scaled ancestors
- Touch-friendly: long-press delay before a drag starts, clicks and taps still work
- Escape to cancel, no model written, everything torn down cleanly
- Zoneless-safe: no
NgZone, no rxjs,OnPushthroughout
Install
npm i @m1z23r/ngx-dndPeer dependencies: @angular/common and @angular/core at ^21.0.0. Nothing else - no rxjs, no @angular/cdk.
Quickstart
import { Component, signal } from '@angular/core';
import { NgxDropListDirective, NgxDragDirective } from '@m1z23r/ngx-dnd';
@Component({
selector: 'app-todo',
imports: [NgxDropListDirective, NgxDragDirective],
template: `
<ul ngxDropList [(items)]="items">
@for (todo of items(); track todo) {
<li ngxDrag>{{ todo }}</li>
}
</ul>
`,
})
export class TodoComponent {
items = signal(['Write README', 'Ship it', 'Bike home']);
}That is the whole thing. No (dropped) handler wired, no array splicing - [(items)] two-way binds and the reordered array shows up in items() the moment you let go.
Kanban recipe
The full nested board: columns are a drop list, cards inside each column are a nested drop list connected by group="cards", columns drag by a handle, one column enforces a WIP limit, an archive zone removes cards, and a template tray copies new cards in.
import { Component, signal } from '@angular/core';
import {
NgxDropListDirective,
NgxDragDirective,
NgxDragHandleDirective,
NgxDropZoneDirective,
IDropEvent,
IDropZoneEvent,
} from '@m1z23r/ngx-dnd';
export interface ICard {
id: string;
title: string;
}
export interface IColumn {
id: string;
title: string;
wipLimit: number | null;
cards: ICard[];
}
@Component({
selector: 'app-board',
imports: [NgxDropListDirective, NgxDragDirective, NgxDragHandleDirective, NgxDropZoneDirective],
template: `
<div class="board" ngxDropList [(items)]="columns" group="columns" orientation="horizontal">
@for (column of columns(); track column.id) {
<div class="column" ngxDrag>
<header ngxDragHandle>
{{ column.title }}
@if (column.wipLimit) {
<span class="wip">{{ column.cards.length }} / {{ column.wipLimit }}</span>
}
</header>
<div
class="card-list"
ngxDropList
[items]="column.cards"
(itemsChange)="setCards(column.id, $event)"
group="cards"
[canEnter]="wipGate(column)"
(dropped)="onCardDropped($event)"
>
@for (card of column.cards; track card.id) {
<div class="card" ngxDrag>{{ card.title }}</div>
}
</div>
</div>
}
</div>
<div class="tray" ngxDropList [(items)]="tray" group="cards" sortingDisabled>
@for (template of tray(); track template.id) {
<div class="card card--template" ngxDrag dragMode="copy">{{ template.title }}</div>
}
</div>
<div
class="archive"
ngxDropZone
group="cards"
(dropped)="onArchived($event)"
>
Drop here to archive
</div>
`,
})
export class BoardComponent {
columns = signal<IColumn[]>([
{ id: 'todo', title: 'To do', wipLimit: null, cards: [{ id: 'c1', title: 'Design API' }] },
{ id: 'doing', title: 'Doing', wipLimit: 3, cards: [{ id: 'c2', title: 'Write engine' }] },
{ id: 'done', title: 'Done', wipLimit: null, cards: [] },
]);
tray = signal<ICard[]>([{ id: 't1', title: 'New card' }]);
setCards(columnId: string, cards: ICard[]) {
this.columns.update((cols) => cols.map((c) => (c.id === columnId ? { ...c, cards } : c)));
}
wipGate(column: IColumn) {
return () => column.wipLimit === null || column.cards.length < column.wipLimit;
}
onCardDropped(event: IDropEvent<ICard>) {
console.log('moved', event.item.title, 'to', event.container.id);
}
onArchived(event: IDropZoneEvent<ICard>) {
if (!event.previousContainer) return;
this.setCards(event.previousContainer.id, event.previousContainer.items().filter((c) => c.id !== event.item.id));
}
}Concepts
group connectivity and isolation. A drop list only accepts drags from another list, or from an ngxDrag outside any list, when both share the same non-null group string. A list with no group bound at all (null, the default) sorts within itself but connects to nothing. Nesting works because the group is just a string match, not a DOM relationship - a "columns" list and a "cards" list can sit one inside the other and never interfere, because the pointer's deepest matching list wins on every frame.
Index-based events with [dragData]. Every drop and sort event reports plain indexes (previousIndex, currentIndex) into whatever array you bound to items, plus the item itself. ngxDrag normally infers its item from the parent list's array at the dragged element's position - that is why the kanban recipe above never binds a value to ngxDrag directly. Set [dragData] explicitly only on a drag that has no parent ngxDropList at all, such as a single free-standing draggable button:
<button ngxDrag [dragData]="{ id: uid(), title: 'New card' }" dragMode="copy">+ Add card</button>Why DOM order is untouched during a drag. Sorting works by measuring geometry once at drag start and then translating siblings with [style.transform] to open a gap - the actual elements never move in the DOM while you drag. Only on drop does the array itself get reordered and written back through model(), and Angular's own @for reconciles the DOM once, from a clean array, with no foreign nodes left over from the drag.
move vs copy. dragMode="move" (the default) removes the item from its source on drop - within one list that is moveItemInArray, across two lists it is transferArrayItem, writing both items models in the same tick. dragMode="copy" never touches the source; only the target's items model is written, via copyArrayItem, so a template tray or palette stays exactly as it was after every drag.
API reference
[ngxDropList]
exportAs="ngxDropList". Implements IDropListRef<T>.
| Input | Type | Default | Description |
|---|---|---|---|
| items | model<T[]> | [] | Two-way bound; the library writes the reordered array back on drop |
| group | string \| null | null | Same string as another list/drag = connected; null = isolated, sorts within itself only |
| orientation | DndOrientation | 'vertical' | Major axis used to resolve the drop index |
| disabled | boolean | false | Rejects every drop and blocks dragging items out |
| sortingDisabled | boolean | false | Accepts transfers in but never reorders in place |
| canEnter | DndEnterPredicate<T> \| null | null | Veto for incoming items - WIP limits, type gates |
| canSort | DndSortPredicate<T> \| null | null | Veto for reordering - pin an item to a fixed position |
| dropListData | unknown | undefined | Arbitrary data surfaced as IDropListRef.data |
| autoScroll | boolean | true | Enables edge auto-scroll while dragging over this list |
| dropListId | string | auto-generated | Explicit id, otherwise a uid is assigned |
Outputs:
| Output | Payload | When |
|---|---|---|
| dropped | IDropEvent<T> | An item is dropped into this list (as source or target) |
| entered | IDndEnterEvent<T> | The dragged item crosses into this list |
| exited | IDndExitEvent<T> | The dragged item crosses out of this list |
| sorted | IDndSortEvent<T> | The target index changes while dragging within this list |
Host classes: .ngx-dnd-list, .ngx-dnd-list--dragging (drag originated here), .ngx-dnd-list--receiving (currently the active target), .ngx-dnd-list--disabled.
[ngxDrag]
exportAs="ngxDrag".
| Input | Type | Default | Description |
|---|---|---|---|
| dragData | T | parent list item at this index | Explicit payload; required outside an ngxDropList |
| dragDisabled | boolean | false | Makes this one item immovable while siblings still drag |
| dragMode | DndDragMode | 'move' | 'move' removes from source on drop, 'copy' leaves it |
| dragDelay | number | - | Overrides the touch/mouse delay before a drag starts |
| dragThreshold | number | 5 | Pixels of movement required to start a drag |
| previewContainer | 'body' \| 'parent' \| HTMLElement | 'body' | Where the floating preview is appended |
| dragPreviewClass | string | - | Extra class applied to the preview element |
Outputs:
| Output | Payload | When |
|---|---|---|
| dragStarted | IDragStartEvent<T> | The drag begins, past the threshold/delay |
| dragMoved | IDragMoveEvent<T> | Every animation frame while dragging (rAF-coalesced) |
| dragEnded | IDragEndEvent<T> | The drag finishes, dropped or cancelled |
Host classes: .ngx-dnd-drag, .ngx-dnd-dragging, .ngx-dnd-disabled.
Interactive descendants never start a drag: input, textarea, select, button, a, [contenteditable], [data-ngx-nodrag].
[ngxDragHandle]
No inputs or outputs. Marks the grab area inside an ngxDrag. If at least one handle exists inside an ngxDrag, only its handles initiate a drag - pointerdown anywhere else on the item is ignored for dragging purposes (clicks still work). Handles register with the nearest ngxDrag through DI and deregister on destroy.
[ngxDropZone]
exportAs="ngxDropZone". A drop target with no list and no sorting - drops emit an event and write nothing.
| Input | Type | Default | Description |
|---|---|---|---|
| group | string \| null | null | Same connectivity rule as [ngxDropList] |
| disabled | boolean | false | Rejects every drop |
| canEnter | DndEnterPredicate \| null | null | Veto for incoming items |
| dropZoneData | unknown | undefined | Arbitrary data surfaced on the zone |
Outputs:
| Output | Payload | When |
|---|---|---|
| dropped | IDropZoneEvent<T> | An item is dropped on this zone - no items model is written |
| entered | - | The dragged item enters this zone |
| exited | - | The dragged item leaves this zone |
Host classes: .ngx-dnd-zone, .ngx-dnd-zone--active, .ngx-dnd-zone--rejected.
*ngxDragPreview
Structural directive declared inside an ngxDrag, replacing the default floating clone.
| Input | Type | Default | Description |
|---|---|---|---|
| matchSize | boolean | true | Copies the source element's width and height onto the preview |
Template context: { $implicit: T, source: IDropListRef | null }.
*ngxDragPlaceholder
Structural directive declared inside an ngxDrag, replacing the default dimmed clone left in the gap.
Template context: { $implicit: T }.
Without either template the defaults are a deep clone of the source element as the preview, and a dimmed clone as the placeholder.
Types
export interface IPoint {
x: number;
y: number;
}
export interface IRect {
x: number;
y: number;
width: number;
height: number;
}
export type DndOrientation = 'vertical' | 'horizontal';
export type DndDragMode = 'move' | 'copy';
export interface IDropListRef<T = unknown> {
readonly id: string;
readonly group: string | null;
readonly data: unknown;
readonly items: Signal<readonly T[]>;
readonly element: HTMLElement;
}
export interface IDropEvent<T = unknown> {
item: T;
previousIndex: number;
currentIndex: number;
previousContainer: IDropListRef<T>;
container: IDropListRef<T>;
mode: DndDragMode;
distance: IPoint;
dropPoint: IPoint;
}
export interface IDropZoneEvent<T = unknown> {
item: T;
previousContainer: IDropListRef<T> | null;
previousIndex: number;
mode: DndDragMode;
dropPoint: IPoint;
}
export interface IDragStartEvent<T = unknown> {
item: T;
source: IDropListRef<T> | null;
index: number;
mode: DndDragMode;
}
export interface IDragMoveEvent<T = unknown> {
item: T;
pointer: IPoint;
delta: IPoint;
}
export interface IDragEndEvent<T = unknown> {
item: T;
dropped: boolean;
cancelled: boolean;
distance: IPoint;
}
export interface IDndEnterEvent<T = unknown> {
item: T;
container: IDropListRef<T>;
index: number;
}
export interface IDndExitEvent<T = unknown> {
item: T;
container: IDropListRef<T>;
}
export interface IDndSortEvent<T = unknown> {
item: T;
container: IDropListRef<T>;
previousIndex: number;
currentIndex: number;
}
export interface IDragState<T = unknown> {
item: T;
source: IDropListRef<T> | null;
sourceIndex: number;
activeList: IDropListRef<T> | null;
activeIndex: number;
pointer: IPoint;
delta: IPoint;
mode: DndDragMode;
}
export type DndEnterPredicate<T = unknown> = (ctx: {
item: T;
from: IDropListRef<T> | null;
to: IDropListRef<T>;
index: number;
}) => boolean;
export type DndSortPredicate<T = unknown> = (ctx: {
item: T;
list: IDropListRef<T>;
fromIndex: number;
toIndex: number;
}) => boolean;
export interface IDragPreviewContext<T = unknown> {
$implicit: T;
source: IDropListRef<T> | null;
}
export interface IDragPlaceholderContext<T = unknown> {
$implicit: T;
}Helpers
Pure array functions - every one returns a new array, never mutates its input, so a model.set() always gets a fresh reference.
export function moveItemInArray<T>(array: readonly T[], from: number, to: number): T[];
export function transferArrayItem<T>(
from: readonly T[],
to: readonly T[],
fromIndex: number,
toIndex: number,
): [T[], T[]];
export function copyArrayItem<T>(from: readonly T[], to: readonly T[], fromIndex: number, toIndex: number): T[];NgxDndService
@Injectable({ providedIn: 'root' })
export class NgxDndService {
readonly dragging: Signal<IDragState | null>;
readonly isDragging: Signal<boolean>;
readonly activeGroup: Signal<string | null>;
cancel(): void;
}Inject it anywhere to read live drag state or cancel a drag programmatically - useful for showing an archive zone only while a drag is in progress, or wiring a keyboard shortcut that aborts one.
provideNgxDnd / INgxDndConfig
export interface INgxDndConfig {
dragThreshold?: number; // 5
touchDelay?: number; // 150
mouseDelay?: number; // 0
animationDuration?: number; // 200
autoScroll?: { threshold?: number; maxSpeed?: number }; // 48 / 20
previewContainer?: 'body' | 'parent'; // 'body'
zIndex?: number; // 1000
}
export function provideNgxDnd(config?: INgxDndConfig): EnvironmentProviders;
export const NGX_DND_CONFIG: InjectionToken<Required<INgxDndConfig>>;provideNgxDnd() is optional - every default works with zero setup. Add it to appConfig.providers only to change timings, auto-scroll feel or the default preview container app-wide.
export const appConfig: ApplicationConfig = {
providers: [provideNgxDnd({ touchDelay: 200, autoScroll: { maxSpeed: 30 } })],
};Recipes
Trash / archive zone
<div class="archive" ngxDropZone group="cards" (dropped)="onArchived($event)">
Drop to archive
</div>onArchived(event: IDropZoneEvent<ICard>) {
if (!event.previousContainer) return;
const remaining = event.previousContainer.items().filter((c) => c !== event.item);
this.setCards(event.previousContainer.id, remaining);
}A drop zone never writes an items model itself - removing the item from its source list is always your call, which is exactly what makes it safe as a delete target.
Copy tray / palette
<div ngxDropList [(items)]="tray" group="cards" sortingDisabled>
@for (template of tray(); track template.id) {
<div ngxDrag dragMode="copy">{{ template.title }}</div>
}
</div>sortingDisabled on the tray keeps its own order fixed; dragMode="copy" on each item means dragging one into a column inserts a copy there and leaves the tray untouched.
WIP limits with canEnter
wipGate = (column: IColumn): DndEnterPredicate<ICard> => {
return () => column.wipLimit === null || column.cards.length < column.wipLimit;
};<div ngxDropList [items]="column.cards" [canEnter]="wipGate(column)" ...>Returning false refuses the drop outright: no gap opens, the list never gets .ngx-dnd-list--receiving, and releasing there reverts the item to its source.
Pinning an item with canSort
canSort: DndSortPredicate<ICard> = ({ fromIndex }) => fromIndex !== 0;<div ngxDropList [items]="column.cards" [canSort]="canSort" ...>Returning false for a given index pins that position - the pinned item's own index never becomes a valid target while everything else around it keeps sorting freely.
Custom preview and placeholder templates
<div class="card" ngxDrag>
{{ card.title }}
<ng-template ngxDragPreview let-card let-source="source">
<div class="card card--preview">{{ card.title }}</div>
</ng-template>
<ng-template ngxDragPlaceholder let-card>
<div class="card card--placeholder"></div>
</ng-template>
</div>Reacting to drag state with NgxDndService.isDragging()
@Component({ ... })
export class BoardComponent {
private readonly dnd = inject(NgxDndService);
readonly showArchive = this.dnd.isDragging;
}@if (showArchive()) {
<div class="archive" ngxDropZone group="cards" (dropped)="onArchived($event)">Drop to archive</div>
}Persisting to a server from (dropped)
async onCardDropped(event: IDropEvent<ICard>) {
await this.api.moveCard(event.item.id, {
columnId: event.container.id,
index: event.currentIndex,
});
}items is already written locally by the time dropped fires, so the UI never waits on the request - treat the call as a fire-and-forget sync, and reconcile from the server response only on error.
Styling
The library injects one small <style id="ngx-dnd-base"> tag into document.head on first use, with only the structural rules it cannot work without (preview positioning, pointer-events, z-index, placeholder box-sizing, touch-action during a drag, sibling transition). Everything cosmetic is yours, through classes and custom properties.
Classes
| Class | Applied to | Meaning |
|---|---|---|
| .ngx-dnd-list | every [ngxDropList] host | Always present |
| .ngx-dnd-list--dragging | a list | The current drag originated in this list |
| .ngx-dnd-list--receiving | a list | This list is currently the active drop target |
| .ngx-dnd-list--disabled | a list | [disabled] is set |
| .ngx-dnd-drag | every [ngxDrag] host | Always present |
| .ngx-dnd-dragging | a drag item | This item is the one currently being dragged |
| .ngx-dnd-disabled | a drag item | [dragDisabled] is set |
| .ngx-dnd-preview | the floating preview element | For the duration of a drag |
| .ngx-dnd-placeholder | the gap element left in the source list | For the duration of a drag |
| .ngx-dnd-zone | every [ngxDropZone] host | Always present |
| .ngx-dnd-zone--active | a drop zone | A droppable item is currently over it |
| .ngx-dnd-zone--rejected | a drop zone | canEnter refuses the item currently over it |
| .ngx-dnd-dragging-active | <body> | For the duration of any drag, anywhere on the page |
Custom properties
| Property | Default | Controls |
|---|---|---|
| --ngx-dnd-transition | 200ms ease | Sibling shift and placeholder transitions |
| --ngx-dnd-preview-shadow | 0 8px 24px rgba(0, 0, 0, 0.2) | Box shadow on the floating preview |
| --ngx-dnd-preview-radius | 4px | Border radius on the floating preview |
| --ngx-dnd-preview-opacity | 0.95 | Opacity of the floating preview |
| --ngx-dnd-preview-scale | 1.02 | Scale transform on the floating preview |
| --ngx-dnd-placeholder-bg | rgba(0, 0, 0, 0.06) | Background of the default placeholder |
| --ngx-dnd-placeholder-border | 2px dashed rgba(0, 0, 0, 0.15) | Border of the default placeholder |
| --ngx-dnd-placeholder-radius | 4px | Border radius of the default placeholder |
| --ngx-dnd-placeholder-opacity | 0.6 | Opacity of the default placeholder |
| --ngx-dnd-zone-active-bg | rgba(34, 197, 94, 0.1) | Background of a zone in --active state |
| --ngx-dnd-zone-active-border | 2px dashed rgba(34, 197, 94, 0.6) | Border of a zone in --active state |
| --ngx-dnd-zone-rejected-bg | rgba(239, 68, 68, 0.1) | Background of a zone in --rejected state |
| --ngx-dnd-zone-rejected-border | 2px dashed rgba(239, 68, 68, 0.6) | Border of a zone in --rejected state |
| --ngx-dnd-z-index | 1000 | Stacking context of the floating preview |
Theming example
.board {
--ngx-dnd-transition: 150ms ease-out;
--ngx-dnd-preview-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
--ngx-dnd-preview-radius: 8px;
--ngx-dnd-preview-scale: 1.04;
--ngx-dnd-placeholder-bg: rgba(99, 102, 241, 0.08);
--ngx-dnd-placeholder-border: 2px dashed rgba(99, 102, 241, 0.4);
--ngx-dnd-zone-active-bg: rgba(99, 102, 241, 0.12);
--ngx-dnd-zone-active-border: 2px dashed rgba(99, 102, 241, 0.6);
}
.ngx-dnd-list--receiving {
background: rgba(99, 102, 241, 0.05);
}Notes
- Auto-scroll: while dragging, the pointer is tested against the inner edges of every scrollable ancestor of the active list, then the window. Within 48px of an edge (
autoScroll.threshold) the container scrolls on a rAF loop, ramping from 0 up to 20px/frame (autoScroll.maxSpeed) by proximity. Both axes scroll independently, so a board can scroll horizontally while a column scrolls vertically at the same time. - Scaled ancestors: all geometry is measured from client rects, which already bake in any CSS scale, so hit-testing, gap position and drop index are correct unmodified inside a
transform: scale()wrapper. The floating preview divides its translation by the accumulated scale whenpreviewContaineris'parent'inside a scaled ancestor. - Touch behaviour: a touch or pen press waits
touchDelay(150ms by default) before it commits to a drag, so the page still scrolls normally on a short press. Moving pastdragThresholdbefore that delay elapses cancels the pending drag instead of starting one. Mouse has no delay by default (mouseDelay: 0). - Escape to cancel: pressing Escape mid-drag, a
pointercancel, a window blur, or callingNgxDndService.cancel()all abort the drag - the preview and placeholder are torn down, noitemsmodel is written, anddragEndedreportscancelled: true. - Zoneless / OnPush safety: every directive and component is
ChangeDetectionStrategy.OnPush, nothing usesNgZone, and there is no rxjs anywhere in the library - it works unmodified in a zoneless application.
License
MIT
