ng-hub-ui-sortable
v22.1.1
Published
Angular bindings for Sortable.js (ng-hub-ui fork of @worktile/ngx-sortablejs)
Maintainers
Readme
ng-hub-ui-sortable
Español | English
Documentation and Live Examples
This package is part of Hub UI, a collection of Angular component libraries for standalone apps.
- Docs: https://hubui.dev/sortable/overview/
- Live examples: https://hubui.dev/sortable/examples/
- Hub UI: https://hubui.dev/
🧩 Library Family ng-hub-ui
This library is part of the ng-hub-ui ecosystem:
- ng-hub-ui-accordion (deprecated — use ng-hub-ui-panels)
- ng-hub-ui-action-sheet
- ng-hub-ui-avatar
- ng-hub-ui-board
- ng-hub-ui-breadcrumbs
- ng-hub-ui-calendar
- ng-hub-ui-dropdown
- ng-hub-ui-ds
- ng-hub-ui-forms
- ng-hub-ui-history
- ng-hub-ui-milestones
- ng-hub-ui-modal
- ng-hub-ui-nav
- ng-hub-ui-paginable
- ng-hub-ui-panels
- ng-hub-ui-portal
- ng-hub-ui-skeleton
- ng-hub-ui-sortable ← You are here
- ng-hub-ui-stepper
- ng-hub-ui-utils
Description
ng-hub-ui-sortable provides a modern Angular integration for SortableJS, making it easy to build drag-and-drop interfaces with a directive-first API. You can turn any list into a sortable experience by adding a single attribute to your template.
The library supports simple list reordering as well as advanced scenarios such as nested lists, cross-list transfers, item cloning, Angular Reactive Forms (FormArray) integration, Angular Signals (WritableSignal) support, and deep customization through SortableJS options and events. Each drag-and-drop interaction stays synchronized with your data model so the UI remains reactive and predictable.
This package is a fork of @worktile/ngx-sortablejs, keeping the proven API while updating the branding, metadata, and Angular integration to match the ng-hub-ui family.
Features
- Directive-based - Simple directive API for adding sortable functionality to any container
- Array binding - Automatically syncs drag-and-drop operations with your data array
- Manual control mode - Opt-in manual array management for full control (similar to Angular CDK)
- Signal support - Native integration with Angular writable signals for reactive state management
- FormArray support - Native integration with Angular Reactive Forms FormArray
- Full SortableJS API - Access to all SortableJS options and events
- Zone integration - Events are properly proxied into Angular's zone for predictable change detection
- Clone mode - Support for cloning items with custom clone functions
- Multi-list support - Drag items between multiple connected lists
- TypeScript support - Full type safety with proper typings
Installation
# Install the component and SortableJS
npm install ng-hub-ui-sortable sortablejs
# Install types for development
npm install -D @types/sortablejsOr using yarn:
yarn add ng-hub-ui-sortable sortablejs
yarn add -D @types/sortablejsPeer Requirements
- Angular
>=18.0.0 - SortableJS
>=1.7.0
Quick Start
Here's a quick example to get you started with ng-hub-ui-sortable. The directive is
standalone, so you import it directly — no module required.
1. Import the standalone directive
import { Component } from "@angular/core";
import { SortableDirective } from "ng-hub-ui-sortable";
@Component({
selector: "app-sortable-demo",
standalone: true,
imports: [SortableDirective],
template: `
<div [hubSortable]="items" [options]="{ animation: 150 }">
@for (item of items; track item) {
<div class="sortable-item">{{ item }}</div>
}
</div>
`,
})
export class SortableDemoComponent {
items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"];
}2. (Optional) Share global options
To apply the same SortableJS options to every directive in the app, register them once with
the standalone provideSortable() provider:
import { bootstrapApplication } from "@angular/platform-browser";
import { provideSortable } from "ng-hub-ui-sortable";
bootstrapApplication(AppComponent, {
providers: [provideSortable({ animation: 150, ghostClass: "sortable-ghost" })],
});Usage
Standalone directive (recommended)
import { Component } from "@angular/core";
import { SortableDirective } from "ng-hub-ui-sortable";
@Component({
selector: "app-sortable-list",
standalone: true,
imports: [SortableDirective],
template: `
<div [hubSortable]="items" [options]="{ animation: 150 }">
@for (item of items; track item) {
<div class="sortable-item">{{ item }}</div>
}
</div>
`,
})
export class SortableListComponent {
items = ["Item 1", "Item 2", "Item 3"];
}Global options with provideSortable() (recommended)
import { provideSortable } from "ng-hub-ui-sortable";
bootstrapApplication(AppComponent, {
providers: [provideSortable({ animation: 150 })],
});NgModule (deprecated, backwards compatibility only)
Deprecated.
SortableModuleandSortableModule.forRoot()are kept only for legacy NgModule applications. Prefer the standaloneSortableDirectiveandprovideSortable().
import { NgModule } from "@angular/core";
import { SortableModule } from "ng-hub-ui-sortable";
@NgModule({
imports: [SortableModule.forRoot({ animation: 150 })],
})
export class AppModule {}Directive API
Primary Inputs
| Input | Type | Description |
| ----------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| items | any[], FormArray, or WritableSignal<any[]> | Model binding that stays in sync with drag-and-drop operations (used with alias [hubSortable]) |
| container | string | Optional CSS selector for the real sortable container when the host is wrapped by another component |
| options | Options | Native SortableJS options object. Provide a new object reference to trigger option updates |
| cloneFunction | (item: any) => any | Custom clone function for clone mode. Allows you to customize how items are cloned |
| autoUpdateArray | boolean | Controls automatic array updates. When true (default), arrays update automatically. When false, you have full control (similar to Angular CDK) |
SortableJS Option Inputs
All SortableJS options can be passed either through the [options] input or as individual inputs:
| Input | Type | Description |
| ----------------------- | -------------------- | --------------------------------------------------------------------------------- |
| group | string \| object | Group name or options for dragging between lists |
| sort | boolean | Enable/disable sorting within the list |
| delay | number | Time in milliseconds to define when sorting should start |
| disabled | boolean | Disable the sortable if set to true |
| draggable | string | CSS selector for draggable items within the container |
| handle | string | CSS selector for drag handle within list items |
| animation | number | Animation speed in milliseconds when sorting |
| ghostClass | string | CSS class applied to the ghost element during drag |
| chosenClass | string | CSS class applied to the chosen element |
| dragClass | string | CSS class applied to the dragging element |
| fallbackOnBody | boolean | Append ghost element to document body |
| fallbackTolerance | number | Number of pixels a point should move before triggering drag |
| fallbackClass | string | CSS class applied when using forceFallback |
| fallbackOffset | object | Fallback offset configuration |
| forceFallback | boolean | Force the fallback to activate |
| filter | string \| function | CSS selector or function to filter items that should not be draggable |
| preventOnFilter | boolean | Call preventDefault on filter event |
| direction | string | Direction of Sortable ('vertical' or 'horizontal', auto-detected if not provided) |
| swapThreshold | number | Threshold of swap zone (0-1) |
| invertSwap | boolean | Inverts swap threshold direction |
| invertedSwapThreshold | number | Threshold when swapping direction is inverted |
| removeCloneOnHide | boolean | Remove clone element when not showing |
| ignore | string | CSS selector for elements to ignore |
| touchStartThreshold | number | Number of pixels a point should move before cancelling a delayed drag event |
| emptyInsertThreshold | number | Distance mouse must be from empty sortable to insert drag element into it |
| dropBubble | boolean | Enable drop bubble |
| dragoverBubble | boolean | Enable dragover bubble |
| dataIdAttr | string | HTML attribute that defines the data id |
| delayOnTouchOnly | boolean | Only delay on touch devices |
| easing | string | Easing for animation (e.g., 'cubic-bezier(1, 0, 0, 1)') |
| setData | function | Function to set data for dragover/drop events |
| store | object | Store module for saving and restoring the sort order |
Outputs
All outputs emit events that are proxied through Angular's zone for proper change detection:
| Output | Type | Description |
| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------- |
| init | EventEmitter<Sortable> | Emits the instantiated Sortable instance on initialization |
| start | EventEmitter<SortableEvent> | Fired when dragging starts |
| end | EventEmitter<SortableEvent> | Fired when dragging ends |
| add | EventEmitter<SortableEvent> | Element is added from another list |
| remove | EventEmitter<SortableEvent> | Element is removed to another list |
| update | EventEmitter<SortableEvent> | Element position is updated within the same list |
| sortEvent | EventEmitter<SortableEvent> | Called when the list is sorted (any change in order) |
| filterEvent | EventEmitter<SortableEvent> | Called when an attempt is made to drag a filtered element |
| change | EventEmitter<SortableEvent> | Called when list changes by adding or removing an item |
| choose | EventEmitter<SortableEvent> | Element is chosen (mouse down on draggable element) |
| unchoose | EventEmitter<SortableEvent> | Element is unchosen (mouse up without drag) |
| clone | EventEmitter<SortableEvent> | Element is cloned when dragging between lists with clone mode |
| move | EventEmitter<{ event: MoveEvent; originalEvent: Event }> | Called during drag move with move event details |
SortableJS Options
All SortableJS options can be passed via options. They are proxied into Angular's zone to keep change detection predictable.
Common Options
import { Options } from "ng-hub-ui-sortable";
interface SortableOptions extends Options {
animation?: number; // Animation speed in ms
handle?: string; // CSS selector for drag handle
filter?: string; // CSS selector for elements to ignore
draggable?: string; // CSS selector for draggable items
ghostClass?: string; // Class for the drop placeholder
chosenClass?: string; // Class for the chosen item
dragClass?: string; // Class for the dragging item
group?: string | object; // Group name for multi-list drag
sort?: boolean; // Enable sorting within list
disabled?: boolean; // Disable the sortable
// ... and many more
}Event Callbacks
{
onStart: (event) => { /* Drag started */ },
onEnd: (event) => { /* Drag ended */ },
onAdd: (event) => { /* Item added from another list */ },
onRemove: (event) => { /* Item removed to another list */ },
onUpdate: (event) => { /* Item order changed within list */ },
onSort: (event) => { /* Any sorting change */ },
onChange: (event) => { /* Item moved within or between lists */ },
onChoose: (event) => { /* Item chosen */ },
onUnchoose: (event) => { /* Item unchosen */ },
onFilter: (event) => { /* Filtered element clicked */ },
onClone: (event) => { /* Clone created */ }
}Examples
Simple Sortable List
<ul [hubSortable]="items">
@for (item of items; track item) {
<li>{{ item }}</li>
}
</ul>With Animation and Handle
<div [hubSortable]="items" [options]="{ animation: 150, handle: '.drag-handle' }">
@for (item of items; track item.id) {
<div class="item">
<span class="drag-handle">☰</span>
{{ item.name }}
</div>
}
</div>Multiple Connected Lists
@Component({
selector: "app-multi-list",
standalone: true,
imports: [SortableDirective],
template: `
<div class="list" [hubSortable]="list1" [options]="options">
@for (item of list1; track item) {
<div>{{ item }}</div>
}
</div>
<div class="list" [hubSortable]="list2" [options]="options">
@for (item of list2; track item) {
<div>{{ item }}</div>
}
</div>
`,
})
export class MultiListComponent {
list1 = ["Item 1", "Item 2", "Item 3"];
list2 = ["Item 4", "Item 5", "Item 6"];
options = {
group: "shared",
animation: 150,
};
}With FormArray
@Component({
selector: "app-form-array",
standalone: true,
imports: [ReactiveFormsModule, SortableDirective],
template: `
<form [formGroup]="form">
<div [hubSortable]="formArray" [options]="{ animation: 150 }">
@for (control of formArray.controls; track control; let i = $index) {
<div>
<input [formControlName]="i" />
</div>
}
</div>
</form>
`,
})
export class FormArrayComponent {
form = new FormGroup({
items: new FormArray([new FormControl("Item 1"), new FormControl("Item 2"), new FormControl("Item 3")]),
});
get formArray() {
return this.form.get("items") as FormArray;
}
}With Angular Signals
import { Component, signal, computed } from "@angular/core";
import { SortableDirective } from "ng-hub-ui-sortable";
@Component({
selector: "app-signal-sortable",
standalone: true,
imports: [SortableDirective],
template: `
<div [hubSortable]="items" [options]="{ animation: 150 }">
@for (item of items(); track item.id) {
<div class="item">{{ item.name }}</div>
}
</div>
<p>Current items: {{ itemNames() }}</p>
`,
})
export class SignalSortableComponent {
items = signal([
{ id: 1, name: "Item 1" },
{ id: 2, name: "Item 2" },
{ id: 3, name: "Item 3" },
]);
// Computed signal that derives from sortable signal
itemNames = computed(() =>
this.items()
.map((item) => item.name)
.join(", "),
);
}Clone Mode
@Component({
selector: "app-clone",
standalone: true,
imports: [SortableDirective],
template: `
<div [hubSortable]="items" [options]="cloneOptions" [cloneFunction]="cloneItem">
@for (item of items; track item.name) {
<div>{{ item.name }}</div>
}
</div>
`,
})
export class CloneComponent {
items = [{ name: "Item 1" }, { name: "Item 2" }];
cloneOptions = {
group: {
name: "clone-group",
pull: "clone",
put: false,
},
};
cloneItem = (item: any) => ({ ...item, name: `${item.name} (copy)` });
}Custom Container
For cases where the directive host is wrapped by another component (e.g., Angular Material):
<mat-list [hubSortable]="items" container=".mat-list-inner">
@for (item of items; track item) {
<mat-list-item>{{ item }}</mat-list-item>
}
</mat-list>Manual Control Mode
By default, ng-hub-ui-sortable automatically updates your arrays when items are dragged and dropped. However, you can opt into manual control mode for complete control over when and how arrays are updated, similar to Angular CDK's drag-and-drop approach.
When to Use Manual Control
Use manual control mode ([autoUpdateArray]="false") when you need to:
- Validate changes before updating the data model
- Make API calls to persist changes to a backend
- Implement undo/redo functionality
- Use immutable data patterns for optimal change detection
- Add custom business logic to drag operations
- Handle errors gracefully before committing changes
How It Works
When autoUpdateArray is set to false, the directive only emits events without modifying your arrays. You're responsible for updating the data using the provided event information.
import { Component } from "@angular/core";
import { SortableDirective, moveItemInArray, transferArrayItem, SortableEvent } from "ng-hub-ui-sortable";
@Component({
selector: "app-tasks",
standalone: true,
imports: [SortableDirective],
template: `
<div [hubSortable]="tasks" [autoUpdateArray]="false" (update)="onUpdate($event)">
@for (task of tasks; track task.id) {
<div class="task">{{ task.title }}</div>
}
</div>
`,
})
export class TasksComponent {
tasks = [
{ id: 1, title: "Design mockups" },
{ id: 2, title: "Implement feature" },
{ id: 3, title: "Write tests" },
];
onUpdate(event: SortableEvent): void {
if (event.oldIndex === undefined || event.newIndex === undefined) {
return;
}
// Validate the drag operation
if (this.isValidMove(event.oldIndex, event.newIndex)) {
// Manually update the array
moveItemInArray(this.tasks, event.oldIndex, event.newIndex);
// Persist to backend
this.apiService.updateTaskOrder(this.tasks).subscribe();
}
}
isValidMove(oldIndex: number, newIndex: number): boolean {
// Add your validation logic here
return true;
}
}Helper Functions
The library provides three helper functions for manual array manipulation:
moveItemInArray
Moves an item within the same array:
import { moveItemInArray } from "ng-hub-ui-sortable";
const items = ["A", "B", "C", "D"];
moveItemInArray(items, 1, 3);
// Result: ['A', 'C', 'D', 'B']transferArrayItem
Transfers an item from one array to another:
import { transferArrayItem } from "ng-hub-ui-sortable";
const source = ["A", "B", "C"];
const target = ["1", "2", "3"];
transferArrayItem(source, target, 1, 2);
// source: ['A', 'C']
// target: ['1', '2', 'B', '3']copyArrayItem
Copies an item to another array without removing it from the source:
import { copyArrayItem } from "ng-hub-ui-sortable";
const source = ["A", "B", "C"];
const target = ["1", "2", "3"];
copyArrayItem(source, target, 1, 2);
// source: ['A', 'B', 'C'] (unchanged)
// target: ['1', '2', 'B', '3']Multiple Lists Example
import { Component } from "@angular/core";
import { SortableDirective, transferArrayItem, SortableEvent } from "ng-hub-ui-sortable";
@Component({
selector: "app-kanban",
standalone: true,
imports: [SortableDirective],
template: `
<div class="board">
<div class="column">
<h3>To Do</h3>
<ul [hubSortable]="todoList" [autoUpdateArray]="false" [options]="{ group: 'tasks' }" (add)="onListAdd($event, todoList)" (update)="onListUpdate($event, todoList)" data-list-id="todo">
@for (item of todoList; track item.id) {
<li>{{ item.title }}</li>
}
</ul>
</div>
<div class="column">
<h3>In Progress</h3>
<ul [hubSortable]="inProgressList" [autoUpdateArray]="false" [options]="{ group: 'tasks' }" (add)="onListAdd($event, inProgressList)" (update)="onListUpdate($event, inProgressList)" data-list-id="inProgress">
@for (item of inProgressList; track item.id) {
<li>{{ item.title }}</li>
}
</ul>
</div>
<div class="column">
<h3>Done</h3>
<ul [hubSortable]="doneList" [autoUpdateArray]="false" [options]="{ group: 'tasks' }" (add)="onListAdd($event, doneList)" (update)="onListUpdate($event, doneList)" data-list-id="done">
@for (item of doneList; track item.id) {
<li>{{ item.title }}</li>
}
</ul>
</div>
</div>
`,
})
export class KanbanComponent {
todoList = [{ id: 1, title: "Task 1" }];
inProgressList = [{ id: 2, title: "Task 2" }];
doneList = [{ id: 3, title: "Task 3" }];
onListAdd(event: SortableEvent, targetList: any[]): void {
if (event.oldIndex === undefined || event.newIndex === undefined) {
return;
}
const sourceList = this.getListByElement(event.from);
if (sourceList) {
transferArrayItem(sourceList, targetList, event.oldIndex, event.newIndex);
// Update backend
this.apiService.moveTask(/* ... */).subscribe();
}
}
onListUpdate(event: SortableEvent, list: any[]): void {
if (event.oldIndex === undefined || event.newIndex === undefined) {
return;
}
moveItemInArray(list, event.oldIndex, event.newIndex);
}
private getListByElement(element: HTMLElement): any[] | null {
const listId = element.getAttribute("data-list-id");
switch (listId) {
case "todo":
return this.todoList;
case "inProgress":
return this.inProgressList;
case "done":
return this.doneList;
default:
return null;
}
}
}Comparison: Automatic vs Manual
| Aspect | Automatic Mode (default) | Manual Mode | | ------------------- | -------------------------- | ------------------------------ | | Array updates | Automatic | You control when and how | | Code required | Minimal | Event handlers needed | | Validation | Not possible before update | Full validation support | | API integration | Update after the fact | Update before committing | | Immutability | Mutates arrays in place | Can create new arrays | | Best for | Simple lists, prototypes | Production apps, complex logic |
Note on Event Handling: SortableJS internally fires multiple events for a single drag operation (e.g., both
updateandsort). ng-hub-ui-sortable handles this automatically, ensuring you only receive one event per operation in manual mode. For more details on the internal event flow, see the Events Guide.
Real-world Use Cases
The ng-hub-ui-sortable component is versatile and can be used in various real-world applications:
- Task Lists - Reorder tasks by priority with drag-and-drop
- Playlist Managers - Arrange media items in custom order
- Form Builders - Drag-and-drop form field ordering
- Dashboard Widgets - User-customizable widget layouts
- Photo Galleries - Rearrange images in albums
- Menu Editors - CMS navigation menu ordering
- Kanban Boards - (For advanced boards, see ng-hub-ui-board)
Troubleshooting
Here are some common issues and how to resolve them:
Drag and drop not working
- Check imports: Ensure the standalone
SortableDirectiveis properly imported - Verify binding: Make sure
[hubSortable]is bound to an array or FormArray - Check container: Items must be direct children of the sortable container
Array not updating
- Reference check: SortableJS modifies the array in place; ensure change detection picks it up
- Zone issues: If using
OnPush, you may need to trigger change detection manually
Events not firing
- Zone proxying: Events are proxied through Angular's zone automatically
- Option reference: Provide a new object reference to
optionswhen updating options
Multi-list issues
- Group name: Ensure all lists share the same
groupname in options - Directive import:
SortableDirectivemust be imported by both components hosting the connected lists
FormArray sync issues
- Direct binding: Bind the FormArray directly, not the parent FormGroup
- Control access: Access controls via
formArray.controlsin your template
If problems persist, open an issue at: https://github.com/carlos-morcillo/ng-hub-ui-sortable/issues
Accessibility
SortableJS reordering is pointer-only: there is no built-in keyboard or screen-reader
path for drag-and-drop, and this wrapper does not add one yet. Where reordering is an
essential capability, pair the sortable list with an alternative affordance operating on
the same array — for example per-item "move up / move down" buttons — so keyboard and
assistive-technology users can achieve the same result. An aria-live announcer story
for drag operations is tracked as future work.
Changelog
See CHANGELOG.md for the full version history.
Contributing
Contributions are welcome! Here's how you can help:
- Fork the repository
- Create your feature branch:
git checkout -b feature/my-new-feature - Commit your changes:
git commit -am 'Add some feature' - Push to the branch:
git push origin feature/my-new-feature - Submit a pull request
Support the Project
If you find this project helpful and would like to support its development, you can buy me a coffee:
Your support is greatly appreciated and helps maintain and improve this project!
License
This project is licensed under the MIT License.
For full license details, see the LICENSE file.
Made with love by Carlos Morcillo Fernandez

