DEV Community

Cover image for The beauty of component composability — and why NeoUI does it differently
Jimmy Petrus
Jimmy Petrus

Posted on

The beauty of component composability — and why NeoUI does it differently

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Notice what didn't change:

  • DataTable has no Draggable flag
  • There's no SortableDataTable variant
  • Column definitions, sorting, selection, toolbar — all work exactly as before
  • The drag handle lives in a normal CellTemplate using 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


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)