Sortable bento board
Drag, keyboard and tap reordering over a subgrid bento layout
npx shadcn@latest add https://ethankd.com/r/sortable-board.jsonOrder: sessions · signups · delivery
Sessions
Sample data
Signups
Sample data
Delivery
Builds
Sample data
Rollbacks
Sample data
Layout saved
"use client";
import { useState } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
/**
* A rearrangeable bento board, where a block is either one tile on the grid or a
* named group whose tiles subgrid onto that same grid.
*
* Four decisions carry this, and three of them are about restraint.
*
* **Reordering sits behind an explicit edit mode.** A board's usefulness depends
* on things staying where they were: a returning reader finds a tile by position
* before they read its title. A board that reorders on any stray drag destroys
* that, so drag handles exist only while editing and the tiles are inert the
* rest of the time.
*
* **Keyboard reordering is not optional** — it is why a drag library is here at
* all. Native HTML5 drag has no keyboard path, and a board only a mouse can
* rearrange fails the standard the rest of the interface meets. dnd-kit's
* keyboard sensor moves a tile with the arrow keys once it is picked up with
* space or enter.
*
* **Up/down buttons are the third path.** The keyboard sensor satisfies SC
* 2.1.1, but SC 2.5.7 asks for a route that needs no DRAGGING and no keyboard —
* and there is no keyboard on a phone. They tap, and they reuse the same persist
* path as the drag, so a reorder made either way is saved identically. Splitting
* those two is how they drift apart.
*
* **The reorder unit is the BLOCK, not the tile.** Letting a tile leave its
* group by drag would dismantle the grouping the layout exists to state, and
* would reintroduce ragged rows: a group's tiles are sized to fill its span
* exactly, so pulling one out leaves a hole behind it.
*
* Order is the layout. The array IS the arrangement, so a reorder saves a list
* of ids and needs no separate position field.
*/
/** A tile placed on the grid. */
export type BoardTile = { id: string; span: BoardSpan; node: React.ReactNode };
/** A named set of tiles that subgrids onto the parent's tracks. */
export type BoardGroup = { id: string; span: BoardSpan; label: string; tiles: BoardTile[] };
export type BoardBlock = BoardTile | BoardGroup;
export type BoardSpan = 2 | 3 | 4 | 6;
function isGroup(block: BoardBlock): block is BoardGroup {
return "tiles" in block;
}
// Tailwind needs the full class name present in source to emit it, so these
// cannot be built by interpolation.
const SPAN_CLASS: Record<number, string> = {
2: "lg:col-span-2",
3: "lg:col-span-3",
4: "lg:col-span-4",
6: "lg:col-span-6",
};
/**
* The contents of a named group: its label, then its tiles.
*
* ## Why this is a Fragment and not a wrapper element
*
* `grid-template-columns: subgrid` only works on a DIRECT child of the grid
* whose tracks are being adopted. The sortable wrapper is already that direct
* child, so a second element here would sit between the grid and the group and
* the subgrid would have nothing to inherit from. The wrapper therefore carries
* the subgrid, and this supplies only what goes inside it.
*
* ## Why subgrid at all
*
* A nested `grid-cols-3` computes its own tracks from its own width. Three tiles
* in such a container are evenly spaced within the container and line up with
* nothing outside it, so a group under a 4+2 row has its internal edges land
* wherever the arithmetic falls: tidy in isolation, ragged against its
* neighbours. Subgrid makes the group adopt the parent's track sizing, so a tile
* of span 2 inside a group of span 6 begins and ends on the same column lines as
* a tile of span 2 on the outer grid. Alignment becomes a property of the system
* rather than something to re-check by eye after every content change.
*
* Baseline 2023 (Firefox 71, Safari 16, Chrome 117). Below `lg` the board is a
* single column and the question does not arise.
*
* ## The padding trap
*
* Padding on a subgrid container is subtracted from its edge tracks, shifting
* the first and last tile inward by exactly that amount and defeating the
* alignment the subgrid was added for. So the wrapper carries no horizontal
* padding, and the group reads as a group through its label and the gap around
* it. The tiles keep their own padding, which is where it belongs.
*/
function BentoGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
{/* Spans every track rather than sitting in the first one, so it titles
the group instead of reading as a caption on the leftmost tile.
h2 because the tiles inside are h3 and the page h1 names the view.
Nesting the grid without nesting the headings would leave a screen
reader user with a flat list of tiles and no statement of what the
group has in common, which is the entire justification for grouping. */}
<h2 className="col-span-full text-iq-micro font-medium tracking-wide uppercase text-iq-ink-muted">
{label}
</h2>
{children}
</>
);
}
function SortableBlock({
id,
span,
editing,
subgrid,
children,
onMove,
canMoveUp,
canMoveDown,
}: {
id: string;
span: number;
editing: boolean;
/**
* True for a group block. The wrapper then becomes the subgrid itself rather
* than merely containing one, because subgrid can only be adopted by a DIRECT
* child of the grid whose tracks it inherits, and this wrapper is that child.
*/
subgrid?: boolean;
children: React.ReactNode;
/** Single-pointer reorder, so dragging is never the only way (WCAG 2.2 SC 2.5.7). */
onMove: (id: string, direction: -1 | 1) => void;
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: !editing,
});
return (
<div
ref={setNodeRef}
style={{
// dnd-kit drives position directly, per frame. Deliberately not a CSS
// transition on `all`.
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
}}
// The wrapper is the grid child, so IT must carry the span. Hardcoding one
// here silently flattens every tile to the same width and leaves dead
// half-rows in the grid.
{...(subgrid ? { "data-bento-group": "" } : {})}
className={`relative col-span-1 min-w-0 ${SPAN_CLASS[span] ?? "lg:col-span-3"} ${
// `grid` in BOTH branches, and the non-subgrid one is a fix rather than
// symmetry. The board stretches this wrapper to the row height, but a
// plain block wrapper lets the tile inside keep its content height, so
// two tiles in one row measure differently and their footers sit on
// different baselines. A single-cell grid stretches the tile to fill.
subgrid ? "grid grid-cols-1 gap-4 lg:grid-cols-subgrid" : "grid"
} ${isDragging ? "opacity-80" : ""}`}
>
{editing ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-xl bg-iq-panel-raised p-0.5">
<button
type="button"
onClick={() => onMove(id, -1)}
disabled={!canMoveUp}
aria-label={`Move ${id} earlier`}
className="grid size-11 place-items-center rounded-lg text-iq-ink-muted transition-colors duration-200 hover:text-iq-ink disabled:pointer-events-none disabled:opacity-40 lg:size-7"
>
<ArrowUp aria-hidden="true" className="size-4" />
</button>
<button
type="button"
// The handle is both the drag affordance and the keyboard control:
// dnd-kit binds its keyboard sensor to these listeners, so this must
// be a real focusable button rather than a decorative grip.
{...attributes}
{...listeners}
aria-label={`Reorder ${id}. Press space to pick up, then arrow keys to move.`}
// touch-action: none is REQUIRED by dnd-kit's PointerSensor. Without
// it the browser's compositor claims the gesture for scrolling
// before any JS runs, and the drag never begins.
style={{ touchAction: "none" }}
className="grid size-11 cursor-grab place-items-center rounded-lg text-iq-ink-muted active:cursor-grabbing lg:size-7"
>
<GripVertical aria-hidden="true" className="size-4" />
</button>
<button
type="button"
onClick={() => onMove(id, 1)}
disabled={!canMoveDown}
aria-label={`Move ${id} later`}
className="grid size-11 place-items-center rounded-lg text-iq-ink-muted transition-colors duration-200 hover:text-iq-ink disabled:pointer-events-none disabled:opacity-40 lg:size-7"
>
<ArrowDown aria-hidden="true" className="size-4" />
</button>
</div>
) : null}
{children}
</div>
);
}
export function SortableBoard({
id: boardId,
blocks,
editing,
onReorder,
}: {
/**
* Distinct per board on the page, and STABLE across the server and client
* renders — a correctness fix rather than tidiness. Without it dnd-kit derives
* its `aria-describedby` target from an internal counter that starts fresh on
* the server and continues on the client, so a handle renders
* `DndDescribedBy-0` in the server HTML and `DndDescribedBy-5` after
* hydration. React reports that as a mismatch and says plainly it "won't be
* patched up", leaving each handle pointing at an element that does not exist:
* the screen-reader instructions for reordering silently go missing.
*/
id: string;
blocks: BoardBlock[];
editing: boolean;
/**
* Persist the new order. Called for both the drag and the move buttons.
* The board updates optimistically before this runs — this is the reader's own
* arrangement, and snapping it back while a request is in flight would read as
* the reorder having failed.
*/
onReorder?: (ids: string[]) => void | Promise<void>;
}) {
const ids = blocks.map((b) => b.id);
const [order, setOrder] = useState(ids);
const [saving, setSaving] = useState(false);
// Follow the source order when it changes underneath: a tile added, or a
// layout reset, must not be masked by stale local state. Joined rather than
// compared by reference, because `blocks` is rebuilt on every render and an
// array identity check would reset local state on each one.
//
// Adjusted DURING render rather than in an effect. The effect form renders the
// stale order once, paints it, then renders again — a visible flash of the old
// arrangement, and a cascading render the lint rule rejects. React re-runs this
// component before touching the DOM, so nothing stale is ever painted.
const idKey = ids.join(",");
const [seenKey, setSeenKey] = useState(idKey);
if (seenKey !== idKey) {
setSeenKey(idKey);
setOrder(ids);
}
const sensors = useSensors(
// A small distance threshold, so a click on a control inside a tile is not
// swallowed as the beginning of a drag.
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const byId = new Map(blocks.map((b) => [b.id, b]));
async function persist(next: string[]) {
setOrder(next);
if (!onReorder) return;
setSaving(true);
try {
await onReorder(next);
} finally {
setSaving(false);
}
}
async function onDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
await persist(arrayMove(order, order.indexOf(String(active.id)), order.indexOf(String(over.id))));
}
function onMove(id: string, direction: -1 | 1) {
const from = order.indexOf(id);
const to = from + direction;
if (from < 0 || to < 0 || to >= order.length) return;
void persist(arrayMove(order, from, to));
}
return (
<DndContext
id={`board-${boardId}`}
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext items={order} strategy={rectSortingStrategy}>
<div data-board className="mb-4 grid grid-cols-1 items-stretch gap-4 lg:grid-cols-6">
{order.map((id, i) => {
const block = byId.get(id);
if (!block) return null;
return (
<SortableBlock
key={id}
id={id}
span={block.span}
editing={editing}
subgrid={isGroup(block)}
onMove={onMove}
canMoveUp={i > 0}
canMoveDown={i < order.length - 1}
>
{isGroup(block) ? (
<BentoGroup label={block.label}>
{block.tiles.map((t) => (
// The tile's own cell, and the reason it exists: inside a
// subgrid the tile would be a direct grid child and its
// own span would win. Those spans were chosen for the flat
// board and do not fit a group, so the layout could not
// control its own composition. This cell owns the span;
// the tile fills it.
<div
key={t.id}
className={`col-span-1 grid min-w-0 ${SPAN_CLASS[t.span] ?? "lg:col-span-3"}`}
>
{t.node}
</div>
))}
</BentoGroup>
) : (
block.node
)}
</SortableBlock>
);
})}
</div>
</SortableContext>
{/* Announced, not merely shown. A keyboard user reordering tiles otherwise
gets no confirmation that the new order was kept. */}
<p aria-live="polite" className="sr-only">
{saving ? "Saving layout" : "Layout saved"}
</p>
</DndContext>
);
}
The preview wires the board into a small stateful demo with edit mode already on; the code tab shows the component you install.
Playground
Order: sessions · signups · delivery
Sessions
Sample data
Signups
Sample data
Delivery
Builds
Sample data
Rollbacks
Sample data
Layout saved