There's a question every UI library author eventually has to answer: when you need to add a new capability to an existing component, what do you do?
It sounds simple. But how you answer it reveals everything about how your library is architected — and whether it will age well or collapse under its own weight.
NeoUI v3.8.0 ships a new Sortable drag-and-drop component. The way it was built is a better answer to that question than most UI libraries give — and a recent refinement takes the idea one step further. Let me show you why.
The traditional approach — and where it breaks
Let's say you have a list view component. It renders a list of items. Works great.
A designer asks: "Can we make the items draggable to reorder?"
In most UI libraries, you have two options.
Option A: Add the feature directly to the component.
Add a Draggable prop. Add a OnReorder event. Add drag handle rendering logic. Add drag-over state. Add keyboard handling. Add the overlay. Now your ListView has grown a second responsibility — it's both a list renderer and a drag-and-drop manager. Every future capability request adds another flag, another event, another conditional render path. Two years later your component has 40 parameters and a 2,000-line implementation.
Option B: Create a new variant.
Ship a SortableListView. Now you have ListView and SortableListView — two components to maintain, two sets of documentation, two places to fix bugs, two implementations to keep in sync. And when the next request comes — "Can the data table rows also be sortable?" — you're back to the same decision: add it to DataTable, or ship a SortableDataTable?
This is how UI libraries end up with enormous surface areas and inconsistent behaviour across component variants. It's also how they become resistant to change — every new capability requires modifying existing code rather than composing new behaviour alongside it.
Enter composability
The shadcn/ui philosophy that NeoUI is built on says something different: capabilities should be composable around components, not baked into them.
Rather than modifying ListView or DataTable to support sorting, you build a Sortable wrapper that can be layered around any component. The existing components stay unchanged. The new behaviour lives in new code. And the same Sortable works for every list-like component — today and in the future.
This is exactly how NeoUI's new Sortable component works.
Sortable in NeoUI — the basic case
The simplest use case looks like this:
<Sortable TItem="TaskItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)">
<SortableContent>
@foreach (var item in items)
{
<SortableItem Value="@item.Id">
<SortableItemHandle />
<span class="flex-1 text-sm font-medium select-none">@item.Name</span>
</SortableItem>
}
</SortableContent>
<SortableOverlay />
</Sortable>
Sortable wraps your content. SortableContent defines the droppable region. SortableItem marks each draggable element. SortableItemHandle is the grip — the six-dot handle users grab. SortableOverlay is the floating ghost shown while dragging.
When a drag completes, OnItemsReordered fires with the new ordered list. Your state updates. Blazor re-renders. Done.
The interaction model is complete: pointer, touch, and full keyboard support (Space/Enter to grab, arrow keys to move, Escape to cancel) — all built into the primitive layer.
Now the interesting part — composing with DataTable
Here's where the design philosophy becomes visible. The question was: how do you make DataTable rows sortable?
The answer is not to add drag-and-drop support to DataTable. Instead, you wrap DataTable with Sortable. DataTable exposes AdditionalRowAttributes — a general-purpose hook for stamping attributes onto each rendered row — and Sortable uses it to wire up drag identity. Neither component knows anything about the other.
<Sortable TItem="TaskItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)"
Context="s">
<SortableContent Class="block">
<DataTable TData="TaskItem" Data="@items"
AdditionalRowAttributes="@s.RowAttributes"
ShowPagination="false"
ShowToolbar="false">
<Columns>
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Id)"
Header=""
Width="40px">
<CellTemplate Context="row">
<SortableItemHandle Class="mx-auto" />
</CellTemplate>
</DataTableColumn>
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Name)"
Header="Task" />
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Status)"
Header="Status" />
</Columns>
</DataTable>
</SortableContent>
<SortableOverlay Class="rounded" />
</Sortable>
Notice what didn't change:
-
DataTablehas noDraggableflag - There's no
SortableDataTablevariant - Column definitions, sorting, selection, toolbar — all work exactly as before
- The drag handle lives in a normal
CellTemplateusing the existing Columns API
Sortable does all the heavy lifting. SortableItemHandle wires itself through the primitive's context — drop it in a CellTemplate and it just works, the same way placing a SortableItemHandle in any other template gives you a grip. DataTable is entirely unaware any of this is happening.
Taking composability further — SortableScope<TItem>
This works. But there's still something slightly awkward: the consumer has to manually construct the attributes dictionary and pass it to AdditionalRowAttributes. That's an implementation concern of Sortable leaking into the consumer's code.
This is the kind of friction composable APIs should eliminate. So we took it a step further.
Sortable<TItem> provides a SortableScope<TItem> through its ChildContent — a typed context following the same Blazor-idiomatic pattern as EditForm with Context="ctx" or QuickGrid with Context="item". It's opt-in: you only add Context="s" when you need what the scope exposes. Without it, Sortable works exactly as shown in the basic examples above.
<Sortable TItem="TaskItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)"
Context="s">
<SortableContent Class="block">
<DataTable TData="TaskItem" Data="@items"
AdditionalRowAttributes="@s.RowAttributes"
ShowPagination="false"
ShowToolbar="false">
<Columns>
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Id)"
Header="" Width="40px">
<CellTemplate Context="row">
<SortableItemHandle Class="mx-auto" />
</CellTemplate>
</DataTableColumn>
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Name)"
Header="Task" />
<DataTableColumn TData="TaskItem" TValue="string"
Property="@(i => i.Status)"
Header="Status" />
</Columns>
</DataTable>
</SortableContent>
<SortableOverlay Class="rounded" />
</Sortable>
s.RowAttributes is all you pass to AdditionalRowAttributes. Everything Sortable needs to track each row is encapsulated inside it — the consumer never sees the wiring. The same applies to SortableItemHandle — drop it anywhere in your template and it finds its context automatically. You don't configure it, you don't pass IDs to it. It just works.
SortableScope<TItem> exposes three properties:
| Property | Type | Description |
|---|---|---|
RowAttributes |
Func<TItem, Dictionary<string, object>?> |
Sortable attributes for each row. Pass directly to AdditionalRowAttributes. |
ActiveId |
string? |
The ID of the item currently being dragged. null when not dragging. |
IsDragging |
bool |
true while any drag is in progress. |
IsItemDragging(item) |
bool |
Returns true if the given item is the one currently being dragged. |
The last three are bonus capabilities — they give you live drag state in your template with zero extra event wiring. Want to dim non-dragged items during a drag? Render a custom placeholder? Show a drag count badge?
<Sortable TItem="TaskItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)"
Context="s">
<SortableContent>
@foreach (var item in items)
{
<SortableItem Value="@item.Id"
Class="@(s.IsItemDragging(item) ? "opacity-40" : "")">
<SortableItemHandle />
<span class="flex-1">@item.Name</span>
@if (s.IsDragging)
{
<Badge Variant="BadgeVariant.Secondary">Moving...</Badge>
}
</SortableItem>
}
</SortableContent>
<SortableOverlay />
</Sortable>
And critically — s.RowAttributes is not DataTable-specific. Any future component that exposes a row/item attribute hook works the same way:
<Sortable TItem="TaskItem" ... Context="s">
<SortableContent Class="block">
<DataTable AdditionalRowAttributes="@s.RowAttributes" ... />
<MyFutureGrid RowAttrs="@s.RowAttributes" ... /> <!-- works too -->
</SortableContent>
</Sortable>
Sortable doesn't know what DataTable is. DataTable doesn't know what Sortable is. SortableScope is the typed bridge between them — and it works for every downstream component, forever, without either side needing to change.
The same pattern with DataView
The same composability applies to DataView — and DataView required zero changes:
<Sortable TItem="ProjectItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)">
<SortableContent Class="block">
<DataView TItem="ProjectItem" Items="@items">
<ListTemplate Context="item">
<SortableItem Value="@item.Id" Class="mb-2">
<SortableItemHandle />
<Item>
<ItemContent>
<ItemTitle>@item.Name</ItemTitle>
<ItemDescription>@item.Description</ItemDescription>
</ItemContent>
</Item>
</SortableItem>
</ListTemplate>
</DataView>
</SortableContent>
<SortableOverlay />
</Sortable>
Place <SortableItem> directly inside <ListTemplate> or <GridTemplate>. That's it. DataView doesn't know anything about drag-and-drop. It doesn't need to.
The architecture behind this: the two-layer model
NeoUI's composability works because every complex component is built in two layers.
Sortable → Styled wrapper with sensible defaults
SortablePrimitive → Headless, zero visual opinion
The headless primitive handles all the hard parts: pointer capture, touch events, keyboard navigation, focus management, ARIA attributes, drag overlay positioning, and the reorder algorithm. It exposes only the behaviour, with zero styling attached.
The styled component layers pre-built CSS on top — the flex flex-col gap-2 layout, the rounded-lg border bg-card item styling, the shadow-lg opacity-90 scale-[1.05] overlay animation. Sensible defaults for the common case.
When you need full control, you escape to the primitive:
<SortablePrimitive TItem="MyItem"
Items="@items"
OnItemsReordered="@(r => items = r)"
GetItemId="@(i => i.Id)">
<SortableContentPrimitive class="flex flex-col gap-2">
@foreach (var item in items)
{
<SortableItemPrimitive Value="@item.Id"
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
<SortableItemHandlePrimitive class="cursor-grab active:cursor-grabbing text-muted-foreground" />
<span class="flex-1 text-sm font-medium select-none">@item.Name</span>
</SortableItemPrimitive>
}
</SortableContentPrimitive>
<SortableOverlayPrimitive class="rounded-lg shadow-lg opacity-90
data-[state=dragging]:scale-[1.05]
transition-transform duration-150" />
</SortablePrimitive>
Every CSS class here is yours. The primitive supplies the behaviour, accessibility, and interaction model. You supply the appearance.
The overlay: a detail worth appreciating
The drag overlay — the ghost that floats under your cursor while dragging — is handled through SortableOverlay / SortableOverlayPrimitive. The implementation is worth understanding because it illustrates another NeoUI design choice.
By default, when ChildContent is null, the JS sensor auto-clones the dragged element (cloneNode(true)) into the overlay frame. The clone fills the frame at 100×100% and carries over the source element's styles — so the ghost looks exactly like the item being dragged, with no extra work.
For <tr> elements (DataTable rows), the sensor snapshots each td/th computed width before cloning and stamps it as an inline width on the clone cells. This preserves the shared table layout geometry even though the clone lives outside its parent <table>. It's a subtle problem and a clean solution.
Visual effects — shadow, opacity, scale — live entirely in CSS via the data-[state=dragging] attribute. JS sets data-state="dragging" after positioning the overlay; Tailwind's data-attribute variants do the rest. No JS inline transforms. No animation logic in JavaScript.
When you want a fully custom ghost, provide a ChildContent:
<SortableOverlay>
@* ChildContent context is the active item ID *@
<SortableOverlay ChildContent="@(activeId => @<div class="my-custom-ghost">
Dragging: @activeId
</div>)" />
</SortableOverlay>
Why this matters beyond drag-and-drop
The Sortable component is a concrete example of a principle that runs through every NeoUI component.
When v3.6.0 shipped Timeline, it didn't add timeline rendering to Card or create a TimelineCard variant. Timeline is its own composable set of sub-components — TimelineItem, TimelineHeader, TimelineConnector, TimelineContent — that you assemble however your layout requires.
When DynamicForm shipped, it didn't modify Input or Select or DatePicker. It reads a schema and renders the existing components exactly as you'd write them by hand — because those components are already composable enough to be driven by data.
When DataView shipped with list/grid switching, it didn't rebuild table or card rendering. It exposed ListTemplate and GridTemplate slots where you compose existing components inside.
SortableScope<TItem> takes this a step further: it's not just composing components together, it's encapsulating the integration contract itself. The consumer gets a typed scope object rather than raw implementation details. Sortable and DataTable remain completely independent — SortableScope is the only shared surface between them, and it's intentionally minimal.
The pattern is always the same: new behaviour as new components, composed around existing ones, with minimal surgical hooks where genuine integration is required — and those hooks encapsulated so consumers never see the machinery.
This is how a library stays maintainable as it grows. It's why NeoUI can ship 100+ components without each one becoming a monolith.
Using Sortable in your project
Available in NeoUI.Blazor v3.8.0:
dotnet add package NeoUI.Blazor --version 3.8.0
The headless primitive is in NeoUI.Blazor.Primitives (included transitively).
Key parameters at a glance:
| Parameter | Description |
|---|---|
Items |
The list to sort. Required. |
GetItemId |
Extracts a unique string ID from each item. Required. |
OnItemsReordered |
Fires after a drop with the new ordered list. |
Orientation |
Vertical (default), Horizontal, Grid, or Mixed. |
OnDragStart |
Fires when drag begins. Receives the active item ID. |
OnDragEnd |
Fires when drag ends. Carries ActiveId, OverId, FromIndex, ToIndex, Moved. |
OnDragCancel |
Fires when drag is cancelled (Escape or pointer cancel). |
Live demos at demos.neoui.io cover vertical lists, horizontal chips, full-item drag, custom handle icons, DataView composability, DataTable integration, and the primitive escape hatch.
Links
- 🌐 Docs: neoui.io
- 🎮 Live demo: demos.neoui.io
- 📦 NuGet: NeoUI.Blazor
- 🐙 GitHub: github.com/jimmyps/blazor-shadcn-ui
- 🐦 X: @neoui_io
What capability would you want to compose onto your existing components next? Drop it in the comments — or open an issue on GitHub.
Top comments (0)