Widget Reorder Input
A "customise your dashboard" panel: drag or keyboard reorder, show and hide switches, a live miniature of the result, and save, cancel and reset.
DOM + CSS0 deps1 file · 19 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from "react";
/**
* WidgetReorderInput — the "customise your dashboard" panel, in the bento
* kit's utility register.
*
* A list of widgets on shared hairlines: drag a row by its grip, or do it
* all from the keyboard (Space picks a row up, the arrow keys move it, Space
* drops it and Escape puts it back; Alt with an arrow moves it in one step).
* A switch shows or hides each widget. Beside the list, a miniature of the
* dashboard redraws as you go, each tile gliding to its new place, so the
* result is visible before it is saved. Save hands the new layout to
* `onSave`; Cancel returns to the last save and Reset to the defaults.
* Every move is announced to screen readers.
*
* Needs Tailwind v4 (container queries are built in). No dependencies beyond
* React.
*/
export type DashWidget = {
id: string;
name: string;
/** Footprint on the dashboard: columns x rows. */
size: "1x1" | "2x1" | "1x2" | "2x2";
visible: boolean;
};
export type WidgetReorderInputProps = {
title?: string;
/** The saved layout, in order. */
widgets: DashWidget[];
/** What Reset returns to. Defaults to `widgets`. */
defaults?: DashWidget[];
/** Store the layout. Resolve when it is stored; throw to show the error. */
onSave?: (widgets: DashWidget[]) => Promise<void> | void;
accent?: string;
className?: string;
};
const MONO = "var(--font-mono, var(--font-geist-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace))";
const OUT = "cubic-bezier(0.16, 1, 0.3, 1)";
/** The kit's reorder spring, shared with the rest of the Bento kit. */
const SPRING = "cubic-bezier(0.34, 1.25, 0.64, 1)";
const SPAN: Record<DashWidget["size"], string> = {
"1x1": "",
"2x1": "col-span-2",
"1x2": "row-span-2",
"2x2": "col-span-2 row-span-2",
};
/** "#rrggbb" if valid, otherwise the fallback. Never interpolated unchecked. */
function safeHex(hex: string | undefined, fallback: string): string {
return /^#[0-9a-f]{6}$/i.test(hex || "") ? (hex as string) : fallback;
}
function isLight(hex: string): boolean {
const n = parseInt(hex.slice(1), 16);
const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] > 0.18;
}
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function move<T>(list: T[], from: number, to: number): T[] {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/** Remember where each child sat, then glide the ones that moved. */
function useFlip(ref: RefObject<HTMLElement | null>, key: string, skip?: string | null) {
const last = useRef(new Map<string, { x: number; y: number }>());
useLayoutEffect(() => {
const root = ref.current;
if (!root) return;
const next = new Map<string, { x: number; y: number }>();
const runs: Animation[] = [];
const still = reduced();
root.querySelectorAll<HTMLElement>("[data-flip]").forEach((el) => {
const id = el.dataset.flip as string;
const pos = { x: el.offsetLeft, y: el.offsetTop };
next.set(id, pos);
const prev = last.current.get(id);
if (still || id === skip) return;
if (!prev) {
if (last.current.size) runs.push(el.animate([{ opacity: 0, transform: "scale(0.92)" }, { opacity: 1, transform: "none" }], { duration: 260, easing: OUT }));
return;
}
const dx = prev.x - pos.x;
const dy = prev.y - pos.y;
if (dx || dy) runs.push(el.animate([{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }], { duration: 380, easing: SPRING }));
});
last.current = next;
return () => runs.forEach((a) => a.finish());
}, [ref, key, skip]);
}
export function WidgetReorderInput({ title = "Customise dashboard", widgets, defaults, onSave, accent, className = "" }: WidgetReorderInputProps) {
const uid = useId();
const tint = safeHex(accent, "#ff5a1f");
const ink = isLight(tint) ? "#0b0b0c" : "#ffffff";
const [saved, setSaved] = useState(widgets);
const [draft, setDraft] = useState(widgets);
const [grabbed, setGrabbed] = useState<{ id: string; from: number } | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const [said, setSaid] = useState("");
const [saveState, setSaveState] = useState<"idle" | "busy" | "done" | "error">("idle");
const listRef = useRef<HTMLUListElement>(null);
const miniRef = useRef<HTMLDivElement>(null);
const refocus = useRef<string | null>(null);
const drag = useRef({ id: "", pointer: 0, startY: 0, grab: 0, pitch: 49, active: false, lastY: 0 });
const saveRun = useRef(0);
const order = draft.map((w) => w.id + (w.visible ? "" : "~")).join(",");
useFlip(listRef, order, dragging);
useFlip(miniRef, order);
// After a keyboard move React may have moved the row's node: focus it again.
useLayoutEffect(() => {
const id = refocus.current;
if (!id) return;
refocus.current = null;
listRef.current?.querySelector<HTMLElement>('[data-grip="' + CSS.escape(id) + '"]')?.focus();
}, [order]);
// While dragging, keep the row under the pointer across re-orders.
useLayoutEffect(() => {
const d = drag.current;
if (!dragging || !d.active) return;
const row = listRef.current?.querySelector<HTMLElement>('[data-flip="' + CSS.escape(d.id) + '"]');
if (row) row.style.transform = "translateY(" + (d.lastY - d.grab - row.offsetTop) + "px)";
});
useEffect(() => {
if (saveState !== "done") return;
const t = window.setTimeout(() => setSaveState("idle"), 1400);
return () => window.clearTimeout(t);
}, [saveState]);
// Changes as a person counts them: rows moved (all rows minus the longest
// run still in saved order, so one drag is one change) plus rows switched.
const savedAt = new Map(saved.map((s, i) => [s.id, i]));
const seq = draft.map((w) => savedAt.get(w.id) ?? -1).filter((i) => i >= 0);
const run: number[] = [];
seq.forEach((v, i) => {
run[i] = 1;
for (let j = 0; j < i; j++) if (seq[j] < v && run[j] + 1 > run[i]) run[i] = run[j] + 1;
});
const moved = seq.length - (run.length ? Math.max(...run) : 0);
const switched = draft.filter((w) => {
const j = savedAt.get(w.id);
return j === undefined || saved[j].visible !== w.visible;
}).length;
const changes = moved + switched;
const dirty = changes > 0;
function announce(text: string) {
setSaid(text);
}
function posText(i: number) {
return "position " + (i + 1) + " of " + draft.length;
}
// ---- Keyboard -----------------------------------------------------------
function onGripKey(e: ReactKeyboardEvent, w: DashWidget, i: number) {
const up = e.key === "ArrowUp";
const down = e.key === "ArrowDown";
if ((up || down) && (e.altKey || grabbed?.id === w.id)) {
e.preventDefault();
const to = Math.max(0, Math.min(draft.length - 1, i + (up ? -1 : 1)));
if (to === i) return;
refocus.current = w.id;
setDraft(move(draft, i, to));
announce(w.name + ", moved to " + posText(to) + ".");
return;
}
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (grabbed?.id === w.id) {
setGrabbed(null);
announce(w.name + ", dropped at " + posText(i) + ".");
} else {
setGrabbed({ id: w.id, from: i });
announce(w.name + ", picked up at " + posText(i) + ". Use the arrow keys to move, Space to drop, Escape to cancel.");
}
return;
}
if (e.key === "Escape" && grabbed?.id === w.id) {
e.preventDefault();
refocus.current = w.id;
setDraft(move(draft, i, grabbed.from));
setGrabbed(null);
announce(w.name + ", move cancelled. Back at " + posText(grabbed.from) + ".");
}
}
// ---- Pointer ------------------------------------------------------------
function local(clientY: number) {
const list = listRef.current as HTMLUListElement;
const r = list.getBoundingClientRect();
return (clientY - r.top) * (list.offsetHeight / (r.height || 1));
}
function rowOf(id: string) {
return listRef.current?.querySelector<HTMLElement>('[data-flip="' + CSS.escape(id) + '"]') ?? null;
}
function place(y: number) {
const d = drag.current;
const row = rowOf(d.id);
if (!row) return;
row.style.transform = "translateY(" + (y - d.grab - row.offsetTop) + "px)";
}
function onGripDown(e: ReactPointerEvent, w: DashWidget) {
if (e.button !== 0 || !listRef.current) return;
const row = rowOf(w.id);
if (!row) return;
const rows = listRef.current.querySelectorAll<HTMLElement>("[data-flip]");
const y = local(e.clientY);
drag.current = {
id: w.id,
pointer: e.pointerId,
startY: y,
grab: y - row.offsetTop,
pitch: rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : row.offsetHeight,
active: false,
lastY: y,
};
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
// Synthetic or already-released pointers cannot be captured.
}
}
function onGripMove(e: ReactPointerEvent) {
const d = drag.current;
if (!d.id || e.pointerId !== d.pointer) return;
const y = local(e.clientY);
d.lastY = y;
if (!d.active) {
if (Math.abs(y - d.startY) < 6) return;
d.active = true;
setDragging(d.id);
setGrabbed(null);
}
const from = draft.findIndex((w) => w.id === d.id);
const to = Math.max(0, Math.min(draft.length - 1, Math.round((y - d.grab) / d.pitch)));
if (from !== -1 && to !== from) setDraft(move(draft, from, to));
place(y);
}
function onGripUp(e: ReactPointerEvent) {
const d = drag.current;
if (!d.id || e.pointerId !== d.pointer) return;
const row = rowOf(d.id);
const wasActive = d.active;
const id = d.id;
drag.current = { ...d, id: "", active: false };
if (!wasActive) return;
if (row) {
const from = row.style.transform;
row.style.transform = "";
if (from && !reduced()) row.animate([{ transform: from }, { transform: "none" }], { duration: 320, easing: SPRING });
}
setDragging(null);
const i = draft.findIndex((w) => w.id === id);
const w = draft[i];
if (w) announce(w.name + ", dropped at " + posText(i) + ".");
}
// ---- Actions ------------------------------------------------------------
function toggle(id: string) {
const w = draft.find((x) => x.id === id);
setDraft(draft.map((x) => (x.id === id ? { ...x, visible: !x.visible } : x)));
if (w) announce(w.name + (w.visible ? " hidden." : " shown."));
}
async function save() {
if (!dirty || saveState === "busy") return;
const run = ++saveRun.current;
const sent = draft;
setSaveState("busy");
try {
if (onSave) await onSave(sent);
if (run !== saveRun.current) return;
setSaved(sent);
setSaveState("done");
announce("Layout saved.");
} catch {
if (run !== saveRun.current) return;
setSaveState("error");
announce("The layout was not saved. Try again.");
}
}
const hint = uid + "-hint";
const btn =
"inline-flex h-9 items-center justify-center rounded-[10px] px-3.5 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-40 motion-reduce:transition-none";
return (
<section
aria-labelledby={uid + "-title"}
className={"@container relative w-full rounded-[20px] bg-[#0f1012] text-white " + className}
style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)" }}
>
<div className="flex items-center justify-between gap-4 px-5 pt-5">
<div>
<h2 id={uid + "-title"} className="text-[17px] font-semibold tracking-[-0.01em]">
{title}
</h2>
<p className="mt-1 text-[13px] text-white/50">Drag to reorder, or use the keyboard. Switch widgets on and off.</p>
</div>
<p className="shrink-0 text-[11px] uppercase tracking-[0.12em] text-white/50" style={{ fontFamily: MONO }}>
{dirty ? changes + (changes === 1 ? " change" : " changes") : "Saved"}
</p>
</div>
<p id={hint} className="sr-only">
Press Space to pick up, the arrow keys to move, Space to drop and Escape to cancel. Alt and an arrow key moves it in one step.
</p>
<p className="sr-only" aria-live="polite" role="status">
{said}
</p>
<div className="grid gap-5 p-5 @3xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
{/* The list, on shared hairlines. */}
<div className="relative isolate rounded-[14px]">
<span aria-hidden className="pointer-events-none absolute inset-0 -z-10 rounded-[14px] bg-white/[0.08]" />
<ul ref={listRef} role="list" className="relative m-px flex flex-col gap-px overflow-hidden rounded-[13px]">
{draft.map((w, i) => {
const held = grabbed?.id === w.id || dragging === w.id;
return (
<li
key={w.id}
data-flip={w.id}
className={"relative flex h-12 items-center gap-3 pr-3 transition-colors duration-150 " + (held ? "z-10 bg-[#1b1c20]" : "bg-[#131417]")}
style={held ? { boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.22)" } : undefined}
>
<button
type="button"
data-grip={w.id}
aria-label={"Move " + w.name}
aria-describedby={hint}
aria-pressed={grabbed?.id === w.id}
onKeyDown={(e) => onGripKey(e, w, i)}
onBlur={() => {
if (grabbed?.id === w.id && refocus.current !== w.id) {
setGrabbed(null);
announce(w.name + ", dropped at " + posText(i) + ".");
}
}}
onPointerDown={(e) => onGripDown(e, w)}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
className={
"grid h-full w-10 shrink-0 touch-none place-items-center text-white/40 outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80 " +
(dragging === w.id ? "cursor-grabbing" : "cursor-grab")
}
>
<svg aria-hidden width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
<circle cx="2.5" cy="2.5" r="1.3" />
<circle cx="7.5" cy="2.5" r="1.3" />
<circle cx="2.5" cy="7" r="1.3" />
<circle cx="7.5" cy="7" r="1.3" />
<circle cx="2.5" cy="11.5" r="1.3" />
<circle cx="7.5" cy="11.5" r="1.3" />
</svg>
</button>
<span className={"min-w-0 flex-1 truncate text-[14px] " + (w.visible ? "text-white" : "text-white/40")}>{w.name}</span>
<span className="shrink-0 text-[11px] text-white/40" style={{ fontFamily: MONO }}>
{w.size}
</span>
<button
type="button"
role="switch"
aria-checked={w.visible}
aria-label={"Show " + w.name}
onClick={() => toggle(w.id)}
className={
"relative h-5 w-9 shrink-0 rounded-full outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#131417] motion-reduce:transition-none " +
(w.visible ? "bg-white/90" : "bg-[#26272b]")
}
>
<span
aria-hidden
className={"absolute top-0.5 left-0.5 size-4 rounded-full transition-transform duration-200 motion-reduce:transition-none " + (w.visible ? "bg-[#0f1012]" : "bg-white/60")}
style={{ transform: w.visible ? "translateX(16px)" : "none", transitionTimingFunction: OUT }}
/>
</button>
</li>
);
})}
</ul>
</div>
{/* The miniature: the dashboard as it will be. A screen inside the
tile, so it drops back to the canvas colour on purpose. */}
<div aria-hidden className="rounded-[14px] bg-[#08090a] p-3" style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.06)" }}>
<p className="mb-2.5 text-[11px] uppercase tracking-[0.12em] text-white/40" style={{ fontFamily: MONO }}>
Preview
</p>
<div ref={miniRef} className="grid grid-cols-4 gap-1.5" style={{ gridAutoRows: "56px", gridAutoFlow: "dense" }}>
{draft
.filter((w) => w.visible)
.map((w) => (
<div
key={w.id}
data-flip={w.id}
className={"flex items-end overflow-hidden rounded-[10px] bg-[#16171a] p-1.5 " + SPAN[w.size]}
style={{
boxShadow: "inset 0 0 0 1px " + (dragging === w.id || grabbed?.id === w.id ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.08)"),
}}
>
<span className="truncate text-[10px] leading-none text-white/55" style={{ fontFamily: MONO }}>
{w.name}
</span>
</div>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 pb-5" style={{ borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 16 }}>
<button
type="button"
disabled={saveState === "busy"}
onClick={() => {
setDraft(defaults ?? widgets);
announce("Defaults restored. Save to keep them.");
}}
className={btn + " -ml-2 text-white/55 hover:text-white"}
>
Reset to defaults
</button>
<div className="flex items-center gap-2">
{saveState === "error" && <span className="text-[12px] text-[#ff8a8a]">Not saved. Try again.</span>}
<button
type="button"
disabled={!dirty || saveState === "busy"}
onClick={() => {
setDraft(saved);
announce("Changes discarded.");
}}
className={btn + " text-white/70 hover:bg-[#16171a] hover:text-white"}
style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.12)" }}
>
Cancel
</button>
<button
type="button"
disabled={!dirty || saveState === "busy"}
onClick={save}
className={btn + " min-w-[88px] font-semibold hover:brightness-110"}
style={{ background: dirty || saveState === "done" ? tint : "#26272b", color: dirty || saveState === "done" ? ink : "rgba(255,255,255,0.6)" } as CSSProperties}
>
{saveState === "busy" ? "Saving…" : saveState === "done" ? "Saved" : "Save"}
</button>
</div>
</div>
</section>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
Every dashboard eventually gets a "customise" button, and most of them open a list you can only drag with a mouse. This one works both ways. Drag a row by its grip, or do it all from the keyboard: Space picks a row up, the arrow keys move it, Space drops it and Escape puts it back where it was, and Alt with an arrow moves a row in one step. A switch shows or hides each widget. Beside the list, a miniature of the dashboard redraws as you go, each tile gliding to its new place on the kit's reorder spring, so the result is visible before you save it. A count of unsaved changes sits in the corner, Cancel returns to the last save and Reset to the defaults. Every pick-up, move and drop is announced to screen readers.
Curator’s note
Part of the Bento Box kit, drop 2, phase 6: the kit's utility register, a settings panel rather than a marketing section. Free because reordering with full keyboard parity is a pattern every product needs, and living-bento-grid already carries the marketing version.

