Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronsUpDown,
  CircleAlert,
  Columns3,
  GripVertical,
  Lock,
  RotateCcw,
  Search,
  X,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/column-picker.json

Prompt

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: order minus everything in hidden, 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 lockReason where 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-disabled plus 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.

On This Page