Column Picker
A table column manager: show/hide toggles, drag or keyboard reorder, pinned columns that refuse to hide with a reason, search and reset — emitting the ordered visible set.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/column-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ColumnPicker" for managing the columns of
a table, using @dnd-kit/core + @dnd-kit/sortable + @dnd-kit/utilities for the
drag, radix-ui's Popover for the overlay and lucide-react for icons.
Contract
- Ship two exports:
- ColumnPickerPanel — forwardRef<HTMLDivElement>, the list itself, usable
inline in a settings pane or a sheet.
- ColumnPicker — forwardRef<HTMLButtonElement>, a toolbar trigger reading
"Columns 9 / 12" that opens the panel in a portalled popover. It owns the
layout state and passes it down controlled, because Radix unmounts the
panel on close and state left in an unmounted tree is lost state.
- Column: { id, label, description?, keywords?: string[], locked?: boolean,
lockReason?: string, defaultHidden?: boolean }. Ids must be unique.
- Value is two arrays, never one:
{ order: string[] // every known id, in paint order
hidden: string[] // ids that are off; anything else is on }
Order and visibility are separate so hiding a column keeps its seat and
showing it again does not drop it at the end of the table.
- Controlled via value / onValueChange, uncontrolled via defaultValue.
onValueChange(value, visible) — the second argument is
order.filter(id => !new Set(hidden).has(id)), i.e. the ordered visible set
the table actually consumes, so the caller never recomputes it.
- Other props: minVisible (default 1), searchThreshold (default 8 — the search
box appears from that many columns up; 0 always, Infinity never),
defaultQuery, label (default "Columns"), searchPlaceholder, onReset,
listMaxHeight (default "18rem"), and on the popover: open / onOpenChange,
triggerLabel, align, panelClassName and disabled.
- The default layout is derived from `columns` itself: declaration order, with
defaultHidden ids off. Reset always returns to that, so "default" cannot
drift away from the column set.
Behavior
- Normalisation runs on every render, over (columns, value), and is what makes
a layout restored from localStorage safe: ids the set no longer has are
dropped from both arrays, ids it has gained are appended in declaration
order, duplicates collapse, and a locked id is removed from `hidden` however
it got there. Everything downstream reads the normalised value.
- Toggling: click / Space / Enter on a row flips it. Two refusals, both spoken
and printed rather than silently ignored:
1. locked column -> its own lockReason;
2. it would take the visible count below the floor
(floor = clamp(minVisible, 0, order.length)) -> "At least N columns have
to stay visible."
- Reordering goes through ONE gate, so drag and keyboard cannot diverge:
requestMove(from, to) refuses when a search is active, when the moved column
is locked, when `to` is out of range ("X is already the first column"), or
when the closed span [min(from,to), max(from,to)] contains a locked id other
than the moved one — a pinned column is an anchor and nothing may cross it.
Otherwise it commits arrayMove(order, from, to).
- Drag: useSortable({ disabled: !movable }) — `true` switches off draggable AND
droppable, so a pinned row is not a drop target either. PointerSensor with a
4px activation distance so a click on the grip stays a click.
- Filtering suspends reordering. The rows you can see during a search are not
the order you would be rewriting, so every grip goes aria-disabled and says
so; the toggles keep working.
- Search: trim, lowercase, split on whitespace; every term must be a substring
of `${id} ${label} ${description} ${keywords.join(" ")}`, so two words narrow
instead of widen. A plain substring scan over a few dozen columns needs no
debounce and therefore no timer to clean up.
- Footer: "Show all" clears `hidden` for the currently listed (i.e. matching)
columns, and Reset restores the columns-derived default. Both are
aria-disabled when they would be no-ops, and both still answer with a reason
when pressed anyway.
- Notices: one { text, tone } state feeds a visible destructive line (aria-
hidden) and an sr-only role="status" region. A single timeout clears it
(2.5s info / 5s refusal) — clearing matters, because a screen reader stays
silent on unchanged text and the next identical refusal must be audible.
- Degenerate cases: no columns at all -> "This picker was given no columns."
and both footer actions inert; a query matching nothing -> the query quoted
back plus a "Clear the search" button; a single column -> its grip is inert
with a reason; minVisible larger than the column set -> clamped, so the
picker can never lock itself.
- Cleanup: clear the notice timeout on unmount; remove the matchMedia listener
through the useSyncExternalStore subscription; reset the dragging flag in
onDragEnd AND onDragCancel.
Keyboard map
- Tab: search box -> grip -> toggle -> ... -> Show all -> Reset. Nothing is
removed from the tab order by going inert.
- Space / Enter on a toggle: show or hide (or speak the refusal).
- Space / Enter on a grip: lift for a dnd-kit keyboard drag; arrows move,
Space drops, Escape cancels.
- Alt + ArrowUp / ArrowDown anywhere in a row: move it one position directly.
- Alt + Home / End: move as far up / down as the pins allow; when that is
nowhere, fall back to a single step so the refusal names the blocker.
- Escape in a non-empty search box clears it and stops propagating; a second
Escape reaches the popover and closes it.
- While a dnd-kit drag is in flight, the Alt shortcuts stand down — otherwise
one keystroke would move the column twice.
ARIA contract
- Each row is an <li> holding two buttons: a grip (dnd-kit's attributes give it
aria-roledescription="sortable" and aria-describedby) with an aria-label of
"Reorder <label>, position i of n", and a toggle carrying
role="checkbox" + aria-checked. The whole <ul> sits inside a role="group"
with the panel's aria-label, and the position is the index in the FULL
order, not in the filtered list.
- Inert states are aria-disabled + a handler guard, NEVER the native disabled
attribute: the browser blurs a control the instant it becomes disabled, and
every inert state here (a pinned toggle, a grip during a search, Reset at the
default) can arrive while the reader is standing on that exact control.
pointer-events stay on, so a press still produces a spoken reason.
- A locked toggle carries aria-describedby -> an sr-only sentence with its
reason, so the reason is spoken on arrival, not only after a refused press.
- Two polite live regions: one for notices, one for "N of M columns match ...".
dnd-kit's own onDragEnd announcement is suppressed (return undefined) because
the drop may still be refused; the panel's status line says what happened.
- The search input has aria-controls pointing at the list container.
- Give DndContext an explicit id (React.useId) — its aria ids come from a
global counter that drifts between server and client.
Rendering & styling
- Semantic tokens only: bg-card / bg-background / bg-muted surfaces,
text-muted-foreground for secondary text, border + border-transparent rows,
bg-primary + text-primary-foreground for a ticked box, text-destructive for a
refusal, ring-ring for focus. No hex, no rgb().
- Merge every className through cn() (clsx + tailwind-merge).
- Reduced motion: motion-reduce:transition-none on rows and chrome,
motion-reduce:animate-none on the popover, and read
(prefers-reduced-motion: reduce) through useSyncExternalStore to drop
dnd-kit's inline transition string — the row still lands where it was
dropped, it just stops travelling. Reordering never depends on animation.
- The popover is portalled (a toolbar usually sits inside overflow-hidden),
animates on enter only, and is bounded by
var(--radix-popover-content-available-height); the list scrolls at
listMaxHeight inside it.
Customization levers
- Density: rows are px-1.5 py-1.5 with a size-4 tick box; drop `description` to
get a single-line row, or raise listMaxHeight / pass "none" when the panel is
inline and should never scroll.
- Which chrome to keep: searchThreshold={Infinity} removes the search box,
drop the "Show all" button for a stricter panel, or drop the trigger badge
and keep only the label.
- Pinning policy: `locked` currently means "cannot hide, cannot move, cannot be
crossed". Relax it to hide-only by removing the span check in requestMove and
passing disabled={{ draggable: false, droppable: false }} to useSortable.
- Grouping: cut the ordered list into runs by a `group` field and render a
heading per run; the reorder maths is index-based and needs no change.
- Tokens: the ticked box is bg-primary; swap to bg-foreground for a neutral
look, or paint the row bg-accent when visible for a heavier "on" state.
- Persistence: the value is two plain string arrays, so JSON.stringify it into
localStorage or a URL param — normalisation is what makes reading it back
safe later.Concepts
- Ordered visible set — the payload a table can actually consume:
orderminus everything inhidden, still in paint order. Keeping order and visibility as two arrays is what lets a hidden column come back to its own seat instead of the far right. - Refusal with a reason — a pinned column, the last visible column and a reorder attempted mid-search all answer instead of ignoring; the sentence is the column's own
lockReasonwhere it has one, printed in the footer and spoken through a polite live region. - Pin as an anchor — a locked row is neither draggable nor a drop target, and any move whose span contains it is rejected, so "always first" and "always last" hold under drag and under the keyboard shortcuts rather than only under the one you tested.
- Filtering suspends reordering — the rows on screen during a search are not the order being rewritten, so grips go inert and say why; toggles stay live, because visibility is per-column and needs no context.
- Normalisation as forward compatibility — every render reconciles the stored value against today's
columns: unknown ids out, new ids appended in declaration order, locked ids forced visible. A layout saved two deploys ago opens instead of throwing. - Inert, never disabled — everything unavailable is
aria-disabledplus a guard in the handler, so a control that goes inert under the reader's own focus keeps that focus, stays announced, and still explains itself when pressed.
Dashboard Grid
A draggable, resizable widget grid: widgets snap to a column grid, collisions push neighbours down deterministically, and every accepted change emits the whole layout for the consumer to persist.
Type Scale
A living typography specimen: every step set in real type, its px, rem, leading and tracking read back off the DOM, with copy-the-token rows and a heading / paragraph / numerals switcher.