Sortable
Pointer and keyboard reordering, styled with Tailwind's *:, data-dragging: and aria-pressed: variants.
Reorderable task list
@bind-Items with ItemKey and ItemLabel.
Drag a grip, or focus it and press Space / Enter to pick up, ↑ / ↓ to move, Space / Enter to drop and Escape to cancel — every step is announced.
The <li> items are rendered by the sortable, so they're styled from the list with *:.
- Write release notes Docs
- Fix flaky login test Bug
- Review pull requests Team
- Update dependencies Chore
Bound order (ids): 1 · 2 · 3 · 4
<BitBhSortable TItem="TaskItem" @bind-Items="tasks"
ItemKey="t => t.Id" ItemLabel="t => t.Title"
aria-label="Tasks"
class="m-0 flex w-full max-w-md list-none flex-col gap-2 p-0 data-dragging:select-none *:flex *:items-center *:gap-3 *:rounded-lg *:border *:border-zinc-200 *:bg-white *:py-2 *:pr-3 *:pl-2 *:text-sm *:transition *:data-dragging:border-blue-600 *:data-dragging:shadow-lg"
Context="s">
<BitBhSortableHandle TItem="TaskItem" aria-label="@($"Reorder {s.Item.Title}")"
class="inline-grid size-7 shrink-0 cursor-grab touch-none select-none place-items-center rounded-md text-base leading-none text-zinc-400 outline-none transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 aria-pressed:cursor-grabbing aria-pressed:bg-blue-600 aria-pressed:text-white">⠿</BitBhSortableHandle>
<span class="flex-1 text-zinc-900">@s.Item.Title</span>
<span class="rounded-full bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600">@s.Item.Tag</span>
</BitBhSortable>
<p class="m-0 text-sm text-zinc-500">
Bound order (ids): <strong class="font-semibold text-zinc-900">@string.Join(" · ", tasks.Select(t => t.Id))</strong>
</p>
@code {
public sealed record TaskItem(int Id, string Title, string Tag);
// Declare the field with the parameter's type (IList<T>) so @bind-Items infers cleanly.
private IList<TaskItem> tasks =
[
new(1, "Write release notes", "Docs"),
new(2, "Fix flaky login test", "Bug"),
new(3, "Review pull requests", "Team"),
new(4, "Update dependencies", "Chore"),
];
}OnReorder event log
OnReorder fires once per committed move with the item, old index and new index — use it to persist the order. Cancelled moves don't fire it.
- Login page
- Password reset
- Audit log
- Dark mode
- CSV export
- No moves yet — reorder an item.
<div class="grid w-full grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] items-start gap-6">
<BitBhSortable TItem="string" @bind-Items="backlog" OnReorder="LogReorder"
aria-label="Sprint backlog"
class="m-0 flex w-full list-none flex-col gap-2 p-0 data-dragging:select-none *:flex *:items-center *:gap-3 *:rounded-lg *:border *:border-zinc-200 *:bg-white *:py-2 *:pr-3 *:pl-2 *:text-sm *:transition *:data-dragging:border-blue-600 *:data-dragging:shadow-lg"
Context="s">
<BitBhSortableHandle TItem="string" aria-label="@($"Reorder {s.Item}")"
class="inline-grid size-7 shrink-0 cursor-grab touch-none select-none place-items-center rounded-md text-base leading-none text-zinc-400 outline-none transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 aria-pressed:cursor-grabbing aria-pressed:bg-blue-600 aria-pressed:text-white">⠿</BitBhSortableHandle>
<span class="flex-1 text-zinc-900">@s.Item</span>
</BitBhSortable>
<ol class="m-0 min-h-full list-none space-y-1.5 rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-3 font-mono text-xs text-zinc-700"
aria-label="Reorder events">
@if (reorderLog.Count == 0)
{
<li class="text-zinc-500">No moves yet — reorder an item.</li>
}
@foreach (var entry in reorderLog)
{
<li>@entry</li>
}
</ol>
</div>
@code {
private IList<string> backlog = ["Login page", "Password reset", "Audit log", "Dark mode", "CSV export"];
private readonly List<string> reorderLog = [];
private void LogReorder(BitBhReorderEventArgs<string> e)
{
reorderLog.Insert(0, $"“{e.Item}” moved from index {e.OldIndex} to {e.NewIndex}");
if (reorderLog.Count > 6) reorderLog.RemoveAt(reorderLog.Count - 1);
}
}Horizontal orientation
Orientation="BitBhOrientation.Horizontal": siblings shift sideways and ← / → move a picked-up item. The whole chip is the handle.
Ranking: Speed › Quality › Cost › Scope
<BitBhSortable TItem="string" @bind-Items="priorities"
Orientation="BitBhOrientation.Horizontal" aria-label="Priorities"
class="m-0 flex list-none gap-2 p-0 *:rounded-full *:border *:border-zinc-300 *:bg-white *:transition *:data-dragging:border-blue-600 *:data-dragging:bg-blue-50 *:data-dragging:shadow-md"
Context="s">
@* The whole chip is the handle: pointer drags and keyboard both start here. *@
<BitBhSortableHandle TItem="string"
class="inline-flex cursor-grab touch-none select-none items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium text-zinc-700 outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 aria-pressed:cursor-grabbing aria-pressed:text-blue-700">
<span class="text-zinc-400" aria-hidden="true">⠿</span>@s.Item
</BitBhSortableHandle>
</BitBhSortable>
<p class="m-0 text-sm text-zinc-500">
Ranking: <strong class="font-semibold text-zinc-900">@string.Join(" › ", priorities)</strong>
</p>
@code {
private IList<string> priorities = ["Speed", "Quality", "Cost", "Scope"];
}Custom announcements
A BitBhSortableAnnouncements rewords (or localizes) the handle instructions and the picked-up / moved / dropped / cancelled messages. The template's Index is the live position.
- 1 Blue in Green
- 2 So What
- 3 Naima
- 4 Take Five
<BitBhSortable TItem="string" @bind-Items="queue" Announcements="queueAnnouncements"
aria-label="Up next"
class="m-0 flex w-full max-w-md list-none flex-col gap-2 p-0 data-dragging:select-none *:flex *:items-center *:gap-3 *:rounded-lg *:border *:border-zinc-200 *:bg-white *:py-2 *:pr-3 *:pl-2 *:text-sm *:transition *:data-dragging:border-blue-600 *:data-dragging:shadow-lg"
Context="s">
<BitBhSortableHandle TItem="string" aria-label="@($"Move {s.Item}")"
class="inline-grid size-7 shrink-0 cursor-grab touch-none select-none place-items-center rounded-md text-base leading-none text-zinc-400 outline-none transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 aria-pressed:cursor-grabbing aria-pressed:bg-blue-600 aria-pressed:text-white">⠿</BitBhSortableHandle>
<span class="inline-grid size-6 place-items-center rounded-full bg-blue-50 text-xs font-semibold tabular-nums text-blue-700">@(s.Index + 1)</span>
<span class="flex-1 text-zinc-900">@s.Item</span>
</BitBhSortable>
@code {
private IList<string> queue = ["Blue in Green", "So What", "Naima", "Take Five"];
private readonly BitBhSortableAnnouncements queueAnnouncements = new()
{
Instructions = "Press Space to grab a song. Use the up and down arrows to change its place in the queue, Space to drop it, or Escape to cancel.",
PickedUp = (song, position, count) => $"Grabbed {song}. It plays as number {position} of {count}.",
Moved = (song, position, count) => $"{song} now plays as number {position} of {count}.",
Dropped = (song, position, _) => position == 1 ? $"{song} plays next." : $"{song} plays as number {position}.",
Cancelled = (song, position) => $"Cancelled. {song} stays at number {position}.",
};
}Disabled
Disabled="true" blocks reordering; style the root and items with data-disabled: and the handles with aria-disabled:. The lock is a BitBhToggle.
- Create account
- Verify email
- Invite teammates
- Connect a repository
<BitBhToggle @bind-Pressed="locked"
class="inline-flex cursor-pointer items-center justify-self-start rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 outline-none transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 data-[state=on]:border-zinc-900 data-[state=on]:bg-zinc-900 data-[state=on]:text-white data-[state=on]:hover:bg-zinc-800">Lock order</BitBhToggle>
<BitBhSortable TItem="string" @bind-Items="steps" Disabled="locked"
aria-label="Onboarding steps"
class="m-0 flex w-full max-w-md list-none flex-col gap-2 p-0 *:flex *:items-center *:gap-3 *:rounded-lg *:border *:border-zinc-200 *:bg-white *:py-2 *:pr-3 *:pl-2 *:text-sm *:transition *:data-dragging:border-blue-600 *:data-dragging:shadow-lg data-disabled:*:bg-zinc-50 data-disabled:*:text-zinc-500"
Context="s">
<BitBhSortableHandle TItem="string" aria-label="@($"Reorder {s.Item}")"
class="inline-grid size-7 shrink-0 cursor-grab touch-none select-none place-items-center rounded-md text-base leading-none text-zinc-400 outline-none transition-colors hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 aria-pressed:cursor-grabbing aria-pressed:bg-blue-600 aria-pressed:text-white aria-disabled:cursor-not-allowed aria-disabled:opacity-50">⠿</BitBhSortableHandle>
<span class="flex-1">@s.Item</span>
</BitBhSortable>
@code {
private bool locked = true;
private IList<string> steps = ["Create account", "Verify email", "Invite teammates", "Connect a repository"];
}