← ui

selection

lib

one selection for any set shown: click, add, range, all, marquee

npx shadcn add @ag/selection
Already installed? Preview an update
npx shadcn add @ag/selection --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Changed: SelectionBar stays one row. Its verbs keep the order they are given (their menu's, so their priority) and as many as the room holds stand in the bar; the rest are a "more" menu at its end, separators kept. The count and the clear always stand. The split is measured from the verbs' own widths and follows the room as it changes, where a long list used to wrap the bar onto a second row.
Updated 2026-10-12 · release notes

shelf controls

uses button, menu, shortcut, tooltip

used by tree, table

the demo above, verbatim

selection.demo.tsx
"use client"

import { Archive, Star, Trash2 } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import {
  actsOn,
  all,
  inOrder,
  NONE,
  only,
  range,
  type Selection,
  toggle,
} from "@/registry/base-nova/lib/selection"
import { Menu, type MenuEntry, verbFor } from "@/registry/base-nova/ui/menu"
import { SelectionBar } from "@/registry/base-nova/ui/selection-bar"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"

// #region list
// Any list, selected the one way: a click is this one alone, ⌘ adds or takes back,
// ⇧ a range from the last click in the list's order, ⌘A all, Esc none. ↑ ↓ move,
// with ⇧ they extend.
const NAMES = [
  "itinerary.pdf",
  "budget.csv",
  "shot list.csv",
  "recipes.md",
  "talk.key",
  "notes.md",
  "camera settings.json",
]
const ORDER = NAMES.map((_, i) => `f${i}`)
const nameOf = (id: string) => NAMES[ORDER.indexOf(id)] as string
// #endregion

// #region verbs
// One list of verbs, read from what it acts on: a star toggle that says which way
// it goes for all of them, and a verb that needs two.
const verbs = (
  ids: readonly string[],
  starred: ReadonlySet<string>,
  star: (ids: readonly string[], on: boolean) => void,
  say: (what: string) => void,
): MenuEntry[] => {
  const all = ids.every((id) => starred.has(id))
  return [
    {
      icon: <Star />,
      label: all ? "unstar" : "star",
      onSelect: () => star(ids, !all),
    },
    ...(ids.length > 1
      ? [
          {
            icon: <Archive />,
            label: `zip ${ids.length}`,
            onSelect: () => say(`zip ${ids.map(nameOf).join(", ")}`),
          },
        ]
      : []),
    "separator" as const,
    {
      icon: <Trash2 />,
      label: "move to trash",
      hint: "mod+backspace",
      destructive: true,
      onSelect: () => say(`trash ${ids.length}`),
    },
  ]
}
// #endregion

function List() {
  // Two files chosen to begin with, so the bar is there before the first click.
  const [selection, setSelection] = React.useState<Selection>(() =>
    toggle(only("f1"), "f3"),
  )
  const [starred, setStarred] = React.useState<ReadonlySet<string>>(new Set())
  const [said, say] = React.useState("")
  const [menu, setMenu] = React.useState<{
    ids: string[]
    at: { x: number; y: number }
  } | null>(null)
  const rows = React.useRef(new Map<string, HTMLButtonElement>())
  const platform = usePlatform()
  const star = (ids: readonly string[], on: boolean) =>
    setStarred((was) => {
      const next = new Set(was)
      for (const id of ids) on ? next.add(id) : next.delete(id)
      return next
    })
  const chosen = inOrder(ORDER, selection)
  const press = (id: string, e: React.MouseEvent) => {
    const mod = e.metaKey || e.ctrlKey
    setSelection(
      e.shiftKey
        ? range(ORDER, selection, id, mod)
        : mod
          ? toggle(selection, id)
          : only(id),
    )
  }
  const onKeyDown = (e: React.KeyboardEvent) => {
    const at = selection.cursor ? ORDER.indexOf(selection.cursor) : -1
    const go = (i: number) => {
      const id = ORDER[Math.max(0, Math.min(ORDER.length - 1, i))] as string
      setSelection(e.shiftKey ? range(ORDER, selection, id) : only(id))
      rows.current.get(id)?.focus()
    }
    if (e.key === "ArrowDown") go(at + 1)
    else if (e.key === "ArrowUp") go(at < 0 ? 0 : at - 1)
    else if (e.key === "Escape") setSelection(NONE)
    else if (e.key === "a" && (e.metaKey || e.ctrlKey))
      setSelection(all(ORDER, selection))
    else {
      // The keys the verbs draw are keys the list answers to.
      const verb = verbFor(verbs(chosen, starred, star, say), e, platform)
      if (!verb || chosen.length === 0) return
      verb.onSelect()
    }
    e.preventDefault()
  }
  return (
    <div className="flex flex-wrap items-start gap-x-6 gap-y-3">
      <div
        role="listbox"
        aria-label="files"
        aria-multiselectable="true"
        tabIndex={-1}
        onKeyDown={onKeyDown}
        className="flex w-64 flex-col"
      >
        {ORDER.map((id) => {
          const selected = selection.ids.has(id)
          return (
            <button
              key={id}
              ref={(node) => {
                if (node) rows.current.set(id, node)
                else rows.current.delete(id)
              }}
              type="button"
              role="option"
              aria-selected={selected}
              tabIndex={id === (selection.cursor ?? ORDER[0]) ? 0 : -1}
              onClick={(e) => press(id, e)}
              onContextMenu={(e) => {
                e.preventDefault()
                setMenu({
                  ids: actsOn(ORDER, selection, id),
                  at: { x: e.clientX, y: e.clientY },
                })
              }}
              className={cn(
                "flex h-7 cursor-default select-none items-center gap-2 rounded-md px-2 text-left text-[13px] text-foreground/80",
                selected && "bg-foreground/10 text-foreground",
              )}
            >
              {nameOf(id)}
              {starred.has(id) && (
                <Star className="ml-auto size-3 fill-current text-foreground/45" />
              )}
            </button>
          )
        })}
      </div>
      <div className="flex w-80 flex-col gap-3">
        <div className="h-10">
          {chosen.length > 0 && (
            <SelectionBar
              count={chosen.length}
              noun="file"
              entries={verbs(chosen, starred, star, say)}
              onClear={() => setSelection(NONE)}
            />
          )}
        </div>
        <p className="text-[12px] text-foreground/50">{said || " "}</p>
      </div>
      <Menu
        entries={menu ? verbs(menu.ids, starred, star, say) : []}
        anchor={menu?.at ?? null}
        onClose={() => setMenu(null)}
      />
    </div>
  )
}

export default function SelectionDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="list"
        glance="wide"
        title="one selection, one list of verbs"
        description="click, ⌘-click, ⇧-click, ↑ ↓ with or without ⇧, ⌘A, Esc. The bar and a right click offer the same verbs: on a selected row for the whole selection, on any other row for that row alone."
        with="list verbs"
      >
        <List />
      </Sample>
    </div>
  )
}

the source, every file an install writes

selection.ts
// THE SELECTION KERNEL, framework-free, one for every set that is shown (a gallery's
// files, a table's rows, a rail's rows): what a click, a ⌘-click, a ⇧-click, ⌘A, Esc
// and a marquee do to a selection, as pure functions over the set's order and the
// selection. The selection is `{ ids, anchor, cursor }`: the anchor is where a range
// starts (the last plain or ⌘ click), the cursor is where the keyboard is. A range is
// always in SET ORDER, never the rectangle on screen: the set's order is the
// meaningful one (from this date to that, this score to that), the keyboard extends
// the same way, and it is the list a slideshow plays. A marquee is the geometric
// selection and arrives here as the ids it touched.
//
// The selection outlives the set: a filter that hides selected items keeps them
// selected, and `shownOf` says how many are visible and how many the filter hides, so
// a bar can say "12 selected, 3 hidden by the filter" and act on the visible ones.
//
// What a verb acts on is decided once, here: `actsOn` is the visible selection when
// the item asked about is in it, else that item alone (a right click on an item
// outside the selection never acts on the selection).

export interface Selection {
  ids: ReadonlySet<string>
  anchor?: string
  cursor?: string
}

export const NONE: Selection = { ids: new Set() }

/** A plain click, or an arrow without ⇧: this one alone. */
export function only(id: string): Selection {
  return { ids: new Set([id]), anchor: id, cursor: id }
}

/** ⌘-click: this one in or out, the rest kept. */
export function toggle(selection: Selection, id: string): Selection {
  const ids = new Set(selection.ids)
  if (ids.has(id)) ids.delete(id)
  else ids.add(id)
  return { ids, anchor: id, cursor: id }
}

/** ⇧-click or ⇧-arrow: from the anchor to `to` in set order. `keep` (⌘ held as well)
 *  adds the range to what was selected instead of replacing it. With no anchor in the
 *  set (it was filtered away, or nothing was ever clicked) the range is `to` alone. */
export function range(
  order: readonly string[],
  selection: Selection,
  to: string,
  keep = false,
): Selection {
  const anchor =
    selection.anchor && order.includes(selection.anchor) ? selection.anchor : to
  const a = order.indexOf(anchor)
  const b = order.indexOf(to)
  if (b < 0) throw new Error(`selection: ${to} is not in the set`)
  const ids = new Set(keep ? selection.ids : [])
  for (let i = Math.min(a, b); i <= Math.max(a, b); i++)
    ids.add(order[i] as string)
  return { ids, anchor, cursor: to }
}

/** ⌘A: every item of the set (what the filter hides stays as it was). */
export function all(order: readonly string[], selection: Selection): Selection {
  return {
    ids: new Set([...selection.ids, ...order]),
    anchor: selection.anchor ?? order[0],
    cursor: selection.cursor ?? order[0],
  }
}

/** A marquee: the ids it touches, added to `base` when `keep` (⌘ or ⇧ held). */
export function area(
  base: Selection,
  touched: readonly string[],
  keep: boolean,
): Selection {
  const ids = new Set(keep ? base.ids : [])
  for (const id of touched) ids.add(id)
  const last = touched[touched.length - 1]
  return { ids, anchor: touched[0] ?? base.anchor, cursor: last ?? base.cursor }
}

/** The keyboard cursor counts only while it points inside the selection: a
 *  deselected grid must never open a ghost. */
export function target(selection: Selection): string | undefined {
  return selection.cursor && selection.ids.has(selection.cursor)
    ? selection.cursor
    : undefined
}

/** The selected ids in set order (what "play the selection" plays). */
export function inOrder(
  order: readonly string[],
  selection: Selection,
): string[] {
  return order.filter((id) => selection.ids.has(id))
}

/** What a verb asked of `id` acts on: the visible selection, in set order, when `id`
 *  is in it, else `id` alone. */
export function actsOn(
  order: readonly string[],
  selection: Selection,
  id: string,
): string[] {
  return selection.ids.has(id) ? inOrder(order, selection) : [id]
}

/** How many selected items the set shows and how many a filter hides. */
export function shownOf(
  order: readonly string[],
  selection: Selection,
): { shown: number; hidden: number } {
  const shown = inOrder(order, selection).length
  return { shown, hidden: selection.ids.size - shown }
}

/** Whether `id` lies inside the selection's own range (anchor to cursor, in set
 *  order): the band the grid washes so a set-order range reads as deliberate. */
export function inRange(
  index: ReadonlyMap<string, number>,
  selection: Selection,
  id: string,
): boolean {
  if (!selection.ids.has(id) || !selection.anchor || !selection.cursor)
    return false
  if (selection.anchor === selection.cursor) return false
  const a = index.get(selection.anchor)
  const b = index.get(selection.cursor)
  const i = index.get(id)
  if (a === undefined || b === undefined || i === undefined) return false
  return i >= Math.min(a, b) && i <= Math.max(a, b)
}
selection-bar.tsx
"use client"

// THE SELECTION'S VERBS, AS A BAR: it rises at the foot of the set while anything is
// selected, says how many (and how many the filter hides, which the verbs leave
// alone), and offers the consumer's verbs for exactly those, the same `MenuEntry`
// list a right click on a selected item opens, so a verb has one definition and two
// faces. Clear (or Esc with focus in the bar) empties the selection. The bar is
// placed by its parent: the gallery and the table pin it to the foot of their own
// scroller; anything else positions it as it likes.
//
// ONE ROW, NEVER WRAPPED (priority+): the verbs keep the order they are given, which is
// their menu's and so their priority, and as many as the room holds stand in the bar;
// the rest are a "more" menu at its end, the same entries, their separators kept. The
// count and the clear always stand. What fits is measured, never guessed: a ruler
// lays every verb out on one line where nothing sees it, and the split is worked out
// again whenever the room or the verbs change.

import { Ellipsis, X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import { Hints, Menu, type MenuEntry } from "@/registry/base-nova/ui/menu"
import { Tip } from "@/registry/base-nova/ui/tooltip"

/** A rule between a bar's groups. */
export const BAR_RULE = "mx-1 h-4 w-px shrink-0 bg-foreground/12"
/** The bar's body: a floating strip at the foot of a set. Any other bar a set raises
 *  there (a table's unsaved changes) wears it, so the bars read as one family. */
export const BAR =
  "flex w-fit max-w-full flex-wrap items-center gap-0.5 rounded-xl border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-md transition-[opacity,translate] duration-200 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"

/** Entries without a separator at either end or two in a row. */
const trimmed = (entries: readonly MenuEntry[]): MenuEntry[] =>
  entries.filter(
    (e, i, all) =>
      e !== "separator" ||
      (i > 0 && i < all.length - 1 && all[i - 1] !== "separator"),
  )
export interface SelectionBarProps {
  /** How many are selected and shown: what the verbs act on. */
  count: number
  /** How many more are selected but hidden by a filter: named, never acted on. */
  hidden?: number
  /** The verbs for the selection, the same list its right-click menu shows. */
  entries: readonly MenuEntry[]
  onClear: () => void
  /** What one of the set is called, plural taken by adding s ("file", "row"). */
  noun?: string
  className?: string
}

export function SelectionBar({
  count,
  hidden = 0,
  entries,
  onClear,
  noun = "item",
  className,
}: SelectionBarProps) {
  const bar = React.useRef<HTMLDivElement>(null)
  const ruler = React.useRef<HTMLDivElement>(null)
  // How many of the entries stand in the bar; the rest are the more menu's.
  const [standing, setStanding] = React.useState(entries.length)
  const [more, setMore] = React.useState<Element | null>(null)
  React.useLayoutEffect(() => {
    const el = bar.current
    const r = ruler.current
    const room = el?.parentElement
    if (!el || !r || !room) return
    const fit = () => {
      // The ruler is the bar on one line: its parts' right edges, from its own left.
      const end = (part: string) => {
        const p = r.querySelector<HTMLElement>(`[data-part="${part}"]`)
        if (!p) throw new Error(`selection-bar: the ruler has no ${part}`)
        return p.offsetLeft + p.offsetWidth
      }
      const tail = (part: string) =>
        (r.querySelector<HTMLElement>(`[data-part="${part}"]`)?.offsetWidth ??
          0) + 2
      // The bar's own frame: its border, and its right padding (the ruler's edges count
      // its left, p-1 as the bar's).
      const frame = el.offsetWidth - el.clientWidth + 4
      const avail = room.clientWidth - frame
      const all = end(`entry-${entries.length - 1}`)
      if (entries.length === 0 || all + tail("clear") <= avail) {
        setStanding(entries.length)
        return
      }
      let n = 0
      for (let i = 0; i < entries.length; i++)
        if (end(`entry-${i}`) + tail("more") + tail("clear") <= avail) n = i + 1
      setStanding(n)
    }
    fit()
    const watch = new ResizeObserver(fit)
    watch.observe(room)
    watch.observe(r)
    return () => watch.disconnect()
  }, [entries.length])
  const shown = trimmed(entries.slice(0, standing))
  const rest = trimmed(entries.slice(standing))
  const verbs = (list: readonly MenuEntry[], ruling: boolean) =>
    list.map((entry, i) =>
      entry === "separator" ? (
        <span
          // biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per render, place is identity
          key={i}
          aria-hidden="true"
          data-part={ruling ? `entry-${i}` : undefined}
          className={BAR_RULE}
        />
      ) : (
        <BarVerb
          // biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per render, place is identity
          key={i}
          entry={entry}
          part={ruling ? `entry-${i}` : undefined}
        />
      ),
    )
  const countLabel = (
    <span
      data-slot="selection-bar-count"
      className="shrink-0 whitespace-nowrap px-2 text-[13px] font-medium tabular-nums"
    >
      {count} {noun}
      {count === 1 ? "" : "s"} selected
      {hidden > 0 && (
        <span className="font-normal text-foreground/50">
          {" "}
          · {hidden} hidden by the filter
        </span>
      )}
    </span>
  )
  return (
    <div
      ref={bar}
      role="toolbar"
      aria-label="selection"
      data-slot="selection-bar"
      onKeyDown={(event) => {
        if (event.key !== "Escape") return
        event.preventDefault()
        onClear()
      }}
      className={cn(BAR, "relative flex-nowrap", className)}
    >
      {/* The ruler: every verb on one line, in a box of no size, never seen or reached. */}
      <div aria-hidden="true" inert className="absolute size-0 overflow-hidden">
        <div ref={ruler} className="flex w-max items-center gap-0.5 p-1">
          {countLabel}
          {entries.length > 0 && <span className={BAR_RULE} />}
          {verbs(entries, true)}
          <span data-part="more" className="flex items-center gap-0.5">
            <span className={BAR_RULE} />
            <span className="size-7" />
          </span>
          <span data-part="clear" className="flex items-center gap-0.5">
            <span className={BAR_RULE} />
            <span className="size-7" />
          </span>
        </div>
      </div>
      {countLabel}
      {shown.length > 0 && <span aria-hidden="true" className={BAR_RULE} />}
      {verbs(shown, false)}
      {rest.length > 0 && (
        <>
          <span aria-hidden="true" className={BAR_RULE} />
          <Tip content="more">
            <Button
              size="icon-sm"
              variant="ghost"
              aria-label="more"
              aria-haspopup="menu"
              onClick={(e) => setMore(more ? null : e.currentTarget)}
              data-popup-open={more ? "" : undefined}
              className="text-foreground/70 hover:text-foreground data-popup-open:bg-foreground/10"
            >
              <Ellipsis />
            </Button>
          </Tip>
          <Menu entries={rest} anchor={more} onClose={() => setMore(null)} />
        </>
      )}
      <span aria-hidden="true" className={BAR_RULE} />
      <Tip content={<>clear the selection</>} keys="escape">
        <Button
          size="icon-sm"
          variant="ghost"
          aria-label="clear the selection"
          onClick={onClear}
          className="text-foreground/55 hover:text-foreground"
        >
          <X />
        </Button>
      </Tip>
    </div>
  )
}

/** A verb, its keyboard keys drawn after its name as faded caps, so the bar teaches
 *  them too without a glyph that reads as a second icon. */
export function BarVerb({
  entry,
  part,
}: {
  entry: Exclude<MenuEntry, "separator">
  /** the ruler's name for it (selection-bar's measure) */
  part?: string
}) {
  return (
    <Button
      size="sm"
      variant="ghost"
      disabled={entry.disabled}
      onClick={entry.onSelect}
      data-part={part}
      className={cn(
        "shrink-0 whitespace-nowrap px-2 text-[13px] font-normal [&_svg]:size-3.5",
        entry.destructive
          ? "text-tone-bad hover:text-tone-bad"
          : "text-foreground/80 hover:text-foreground",
      )}
    >
      <span aria-hidden="true" className="grid size-4 place-items-center">
        {entry.icon}
      </span>
      {entry.label}
      {entry.hint && (
        <Hints hint={entry.hint} keysOnly className="ml-1.5 opacity-60" />
      )}
    </Button>
  )
}