Choose items, extend a selection, or select a rectangle of shapes or cells.

createSelection chooses item keys and extends ranges, createLasso selects items with a drawn rectangle, and createCellRange selects a block of grid cells.

Import them from @ui-sdk/core. Pair them with Focus and Keys to move between items and choose them.

Selecting Items

Choose One

Keep the selected keys in a string[], even when only one item can be chosen. In api, add createSelection alongside the existing roving focus behavior:

const change = createChange(context)
const selection = createSelection(context, {
  key: "value",
  set: (value, details) => change("value", value, details),
  order: () => roving.items().map((item) => item.key),
})

Start value at [] and return selection from api. The set callback uses the same change pattern as the guides.

The behavior gives you methods to call from your event handlers. For example, a pointer press can choose one item:

pointerdown: (event, { api, key }) => api.selection.choose(key, { reason: "item-press", event }),

choose selects that item. toggle also lets the user clear it. Add this branch to your existing key handler for Space:

if (event.key === " ") {
  event.preventDefault()
  selection.toggle(key, { reason: "item-press", event })
}

This plan picker uses both. selection.isSelected(key) lets its Enter handler check whether a plan is chosen before continuing.

Console
import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createSelection,
  definePrimitive,
  selects,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type PlansState = { value: string[]; focused: string | null }

type PlansOptions = {
  onValueChange?: (
    value: string[],
    details: ChangeDetails<"item-press">
  ) => void
  onContinue?: (plan: string) => void
}

const plans = definePrimitive({
  name: "plans",
  options: { onValueChange: "callback", onContinue: "callback" },
  state: (_options: PlansOptions): PlansState => ({ value: [], focused: null }),
  api: (context) => {
    const change = createChange(context)
    const roving = createRovingFocus<PlansState, { value: string }>(context, {
      slot: "plan",
      key: "focused",
      orientation: () => "vertical",
    })
    const selection = createSelection(context, {
      key: "value",
      set: (value, details) => change("value", value, details),
      order: () => roving.items().map((item) => item.key),
    })
    return {
      roving,
      selection,
      keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
        if (event.key === " ") {
          event.preventDefault()
          selection.toggle(key, { reason: "item-press", event })
        } else if (event.key === "Enter" && selection.isSelected(key)) {
          context.options().onContinue?.(key)
        } else {
          roving.keydown(event, el)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({ attrs: () => ({ role: "listbox" }) }),
    plan: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(state.value.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      on: {
        keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
        pointerdown: (event, { api, key }) =>
          api.selection.choose(key, { reason: "item-press", event }),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(plans, { root: "div", plan: "div" })

const Plans = parts.Root

const PlansPlan = parts.Plan

const options = [
  { name: "Free", price: "$0" },
  { name: "Pro", price: "$12 / month" },
  { name: "Team", price: "$30 / month" },
]

function Example() {
  const log = useLog()
  return (
    <Plans
      aria-label="Plan"
      onValueChange={(value: string[]) =>
        log(value.length ? `Chose ${value[0]}` : "Cleared the plan")
      }
      onContinue={(plan: string) => log(`Continuing with ${plan}`)}
    >
      {options.map(({ name, price }) => (
        <PlansPlan key={name} value={name}>
          {name}
          <span data-slot="hint">{price}</span>
        </PlansPlan>
      ))}
    </Plans>
  )
}

export { Example, plans, Plans, PlansPlan, type PlansOptions, type PlansState }
Click a plan, or use the arrows and Space to choose one. Space on the chosen plan clears it; Enter continues with it, and the console logs each step.

For a listbox, give the root role="listbox", each item role="option", and set its aria-selected from state.value.includes(key). Keep the focus wiring from Focus and Keys.

Choose Many

Add multiple to the selection options:

multiple: () => true,

Now toggle adds or removes one item while keeping the others. Use it for both pointer presses and Space, as in this topping picker. A listbox that allows this also needs aria-multiselectable="true" on its root.

import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createSelection,
  definePrimitive,
  selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type ToppingsState = { value: string[]; focused: string | null }

const toppings = definePrimitive({
  name: "toppings",
  state: (): ToppingsState => ({ value: ["Cheese"], focused: null }),
  api: (context) => {
    const change = createChange(context)
    const roving = createRovingFocus<ToppingsState, { value: string }>(
      context,
      {
        slot: "topping",
        key: "focused",
        orientation: () => "horizontal",
      }
    )
    const selection = createSelection(context, {
      key: "value",
      set: (value, details) => change("value", value, details),
      order: () => roving.items().map((item) => item.key),
      multiple: () => true,
    })
    return {
      roving,
      selection,
      keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
        if (event.key !== " ") {
          return roving.keydown(event, el)
        }
        event.preventDefault()
        selection.toggle(key, { reason: "item-press", event })
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
    }),
    topping: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(state.value.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      on: {
        keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
        pointerdown: (event, { api, key }) =>
          api.selection.toggle(key, { reason: "item-press", event }),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(toppings, { root: "div", topping: "div" })

const Toppings = parts.Root

const ToppingsTopping = parts.Topping

const names = ["Cheese", "Basil", "Olives", "Mushrooms", "Peppers"]

function Example() {
  return (
    <Toppings aria-label="Toppings">
      {names.map((name) => (
        <ToppingsTopping key={name} value={name}>
          {name}
        </ToppingsTopping>
      ))}
    </Toppings>
  )
}

export { Example, toppings, Toppings, ToppingsTopping, type ToppingsState }
Click toppings to add or remove them. With the keyboard, use Left and Right, then Space.

Ranges and Select All

extend selects from an anchor to another item. The anchor is the last item chosen or toggled without Shift. These three calls give a file list its usual pointer interactions:

press(event: PointerEvent, key: string) {
  const details = { reason: "item-press" as const, event }
  if (event.shiftKey) {
    selection.extend(key, { ...details, from: context.store.get().focused })
  } else if (event.metaKey || event.ctrlKey) {
    selection.toggle(key, details)
  } else {
    selection.choose(key, details)
  }
},

For Shift with the arrows, use the result of the existing focus handler:

const moved = roving.keydown(event, el)
if (moved?.shift) {
  selection.extend(moved.key, {
    from: moved.from,
    reason: "list-navigation",
    event,
  })
}

from supplies the starting point when there isn't an anchor yet. selection.anchor() returns the current anchor's key.

A range keeps what was selected when the anchor was set, then adds the items between the anchor and the target. Pass additive: false to select only the range. Supplying a different selection from outside starts the next range over.

Console
import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createSelection,
  definePrimitive,
  selects,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type FilesState = { value: string[]; focused: string | null }

type FilesOptions = {
  replaceRanges?: boolean
  onValueChange?: (
    value: string[],
    details: ChangeDetails<"item-press" | "list-navigation">
  ) => void
}

const files = definePrimitive({
  name: "files",
  options: { replaceRanges: "boolean", onValueChange: "callback" },
  state: (_options: FilesOptions): FilesState => ({ value: [], focused: null }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const additive = () => !context.options().replaceRanges
    const roving = createRovingFocus<FilesState, { value: string }>(context, {
      slot: "file",
      key: "focused",
    })
    const selection = createSelection(context, {
      key: "value",
      set: (value, details) => change("value", value, details),
      order: () => roving.items().map((item) => item.key),
      multiple: () => true,
    })
    return {
      roving,
      keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
        if ((event.metaKey || event.ctrlKey) && event.key === "a") {
          event.preventDefault()
          selection.toggleAll({ reason: "list-navigation", event })
        } else if (event.key === " ") {
          event.preventDefault()
          selection.toggle(key, { reason: "item-press", event })
        } else {
          const moved = roving.keydown(event, el)
          if (moved?.shift) {
            selection.extend(moved.key, {
              from: moved.from,
              additive: additive(),
              reason: "list-navigation",
              event,
            })
          }
        }
      },
      press(event: PointerEvent, key: string) {
        const why = { reason: "item-press" as const, event }
        if (event.shiftKey) {
          selection.extend(key, {
            ...why,
            from: store.get().focused,
            additive: additive(),
          })
        } else if (event.metaKey || event.ctrlKey) {
          selection.toggle(key, why)
        } else {
          selection.choose(key, why)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
    }),
    file: slot<{ value: string }, { selected: boolean }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      state: (state, { key }) => ({ selected: state.value.includes(key) }),
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(state.value.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-selected": state.value.includes(part.key) ? "" : undefined,
      }),
      on: {
        keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
        pointerdown: (event, { api, key }) => api.press(event, key),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(files, { root: "div", file: "div" })

const Files = parts.Root

const FilesItem = parts.File

const names = [
  "notes.md",
  "budget.xlsx",
  "photo.jpg",
  "slides.key",
  "draft.docx",
  "logo.svg",
]

function Example() {
  const log = useLog()
  const [replace, setReplace] = React.useState(false)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={replace}
            onChange={(event) => setReplace(event.target.checked)}
          />
          Ranges replace the selection
        </label>
      </div>
      <Files
        aria-label="Files"
        replaceRanges={replace}
        onValueChange={(value: string[]) =>
          log(`${value.length} selected: ${value.join(", ") || "none"}`)
        }
      >
        {names.map((name) => (
          <FilesItem key={name} value={name}>
            {name}
          </FilesItem>
        ))}
      </Files>
    </div>
  )
}

export { Example, files, Files, FilesItem, type FilesOptions, type FilesState }
Click a file, then Shift-click another for a range. Control or Command-click toggles one file. Turn on Ranges replace the selection to compare.

In multiple mode, toggleAll selects every available key in order, or clears them if they're already selected. Call it from your Control or Command+A handler after preventing the browser's default action:

selection.toggleAll({ reason: "list-navigation", event })

order determines what ranges and select-all can reach. For a virtualized list, pass the full list of keys, including items that aren't rendered.

Items That Can't Be Selected

Use selectable to skip keys that shouldn't be chosen, toggled, or added to a range:

selectable: (key) => !context.options().owners.includes(key),

In this member list, owners start selected. The demo uses toggle and toggleAll, which leave those owners alone.

import * as React from "react"
import {
  createChange,
  createRovingFocus,
  createSelection,
  definePrimitive,
  selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type MembersState = { value: string[]; focused: string | null }

type MembersOptions = {
  owners: string[]
}

const members = definePrimitive({
  name: "members",
  options: { owners: "list" },
  state: (options: MembersOptions): MembersState => ({
    value: [...options.owners],
    focused: null,
  }),
  api: (context) => {
    const change = createChange(context)
    const roving = createRovingFocus<MembersState, { value: string }>(context, {
      slot: "member",
      key: "focused",
    })
    const selection = createSelection(context, {
      key: "value",
      set: (value, details) => change("value", value, details),
      order: () => roving.items().map((item) => item.key),
      multiple: () => true,
      selectable: (key) => !context.options().owners.includes(key),
    })
    return {
      roving,
      selection,
      keydown(event: KeyboardEvent, el: HTMLElement, key: string) {
        if ((event.metaKey || event.ctrlKey) && event.key === "a") {
          event.preventDefault()
          selection.toggleAll({ reason: "list-navigation", event })
        } else if (event.key === " ") {
          event.preventDefault()
          selection.toggle(key, { reason: "item-press", event })
        } else {
          roving.keydown(event, el)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ role: "listbox", "aria-multiselectable": "true" }),
    }),
    member: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        role: "option",
        "aria-selected": String(state.value.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      on: {
        keydown: (event, { el, api, key }) => api.keydown(event, el!, key),
        pointerdown: (event, { api, key }) =>
          api.selection.toggle(key, { reason: "item-press", event }),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(members, { root: "div", member: "div" })

const Members = parts.Root

const MembersMember = parts.Member

const names = [
  "Hollis Grant",
  "Inรจs Moreau",
  "Kofi Mensah",
  "Wren Takahashi",
  "Tomasz Wรณjcik",
]
const owners = ["Hollis Grant"]

function Example() {
  return (
    <Members owners={owners} aria-label="Members">
      {names.map((name) => (
        <MembersMember
          key={name}
          value={name}
          aria-disabled={owners.includes(name) || undefined}
        >
          {name}
          {owners.includes(name) && <span data-slot="hint">Owner</span>}
        </MembersMember>
      ))}
    </Members>
  )
}

export {
  Example,
  members,
  Members,
  MembersMember,
  type MembersOptions,
  type MembersState,
}
Focus a member and press Control or Command+A twice. Hollis Grant stays selected while the other members are selected, then cleared.

This doesn't pin an item in the selection: choose and a range with additive: false can replace the whole value. If some keys must always stay selected, enforce that in your set callback.

Rectangle Selection

Draw a Rectangle

createLasso selects items touched by a rectangle. Start with selected: string[] and lasso: LassoDraft | null in state, with lasso set to null. Add lasso to the primitive's live fields, as covered in Live State.

Create the behavior in api and return it as lasso:

const lasso = createLasso(context, {
  slot: "shape",
  draft: "lasso",
  selected: () => context.store.get().selected,
  set: (keys, details) => change("selected", keys, details),
})

Give the surface position: relative, touch-action: none and user-select: none so drawing doesn't scroll the page or select text. Start when the pointer lands on its empty space:

pointerdown: (event, { el, api }) => {
  if (el && event.target === el) {
    api.lasso.pointerdown(event, el)
  }
},

Keep the rectangle part rendered. Its style hides it when there's no drag:

lasso: slot({
  attrs: () => ({ "aria-hidden": "true" }),
  live: live((state, { api }) => ({ style: api.lasso.style(state) })),
}),
Console
import * as React from "react"
import {
  createChange,
  createLasso,
  definePrimitive,
  live,
  type ChangeDetails,
  type LassoDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type ShapesState = { selected: string[]; lasso: LassoDraft | null }

type ShapesOptions = {
  onSelectedChange?: (
    selected: string[],
    details: ChangeDetails<"lasso">
  ) => void
}

const shapes = definePrimitive({
  name: "shapes",
  options: { onSelectedChange: "callback" },
  state: (_options: ShapesOptions): ShapesState => ({
    selected: [],
    lasso: null,
  }),
  live: ["lasso"],
  api: (context) => {
    const change = createChange(context)
    return {
      lasso: createLasso<ShapesState>(context, {
        slot: "shape",
        draft: "lasso",
        selected: () => context.store.get().selected,
        set: (keys, details) => change("selected", keys, details),
      }),
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api }) =>
          event.target === el && api.lasso.pointerdown(event, el!),
      },
    }),
    shape: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => ({
        "data-selected": state.selected.includes(key) ? "" : undefined,
      }),
      live: live<ShapesState, any, { value: string }>(
        (state, { key, api }) => ({
          "data-lassoed": api.lasso.touches(state, key) ? "" : undefined,
        })
      ),
    }),
    lasso: slot({
      attrs: () => ({ "aria-hidden": "true" }),
      live: live<ShapesState, any, {}>((state, { api }) => ({
        style: api.lasso.style(state),
      })),
    }),
  }),
})

const parts = createParts(shapes, { root: "div", shape: "div", lasso: "div" })

const Shapes = parts.Root

const ShapesItem = parts.Shape

const ShapesLasso = parts.Lasso

const items = [
  { key: "Circle", x: 24, y: 24, round: true },
  { key: "Square", x: 134, y: 36, round: false },
  { key: "Dot", x: 70, y: 130, round: true },
  { key: "Tile", x: 206, y: 116, round: false },
]

function Example() {
  const log = useLog()
  return (
    <Shapes
      aria-label="Shapes"
      onSelectedChange={(selected: string[]) =>
        log(`Selected: ${selected.join(", ") || "none"}`)
      }
    >
      {items.map((item) => (
        <ShapesItem
          key={item.key}
          value={item.key}
          data-shape={item.round ? "round" : undefined}
          style={{ left: item.x, top: item.y }}
        />
      ))}
      <ShapesLasso />
    </Shapes>
  )
}

export {
  Example,
  shapes,
  Shapes,
  ShapesItem,
  ShapesLasso,
  type ShapesOptions,
  type ShapesState,
}
Drag from empty space to select shapes; the console logs the selection. Hold Shift to add to it; press Escape before releasing to cancel the rectangle.

lasso.touches(state, key) tells you which items belong to the preview selection. With Shift, that includes items selected before the drag. Use it for a data-lassoed highlight.

The selection changes once, when the pointer is released, with the reason "lasso". Escape cancels the drag and keeps the previous selection.

Items It Skips

Add selectable to exclude items from the rectangle's hits, and message to customize the screen reader announcement:

selectable: (key) => !context.options().locked.includes(key),
message: (count) => `${count} ${count === 1 ? "photo" : "photos"} selected`,

The announcement runs after the selection request is accepted.

import * as React from "react"
import {
  createChange,
  createLasso,
  definePrimitive,
  live,
  type LassoDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type PhotosState = { selected: string[]; lasso: LassoDraft | null }

type PhotosOptions = {
  locked: string[]
}

const photos = definePrimitive({
  name: "photos",
  options: { locked: "list" },
  state: (_options: PhotosOptions): PhotosState => ({
    selected: [],
    lasso: null,
  }),
  live: ["lasso"],
  api: (context) => {
    const change = createChange(context)
    return {
      lasso: createLasso<PhotosState>(context, {
        slot: "photo",
        draft: "lasso",
        selected: () => context.store.get().selected,
        set: (keys, details) => change("selected", keys, details),
        selectable: (key) => !context.options().locked.includes(key),
        message: (count) =>
          `${count} ${count === 1 ? "photo" : "photos"} selected`,
      }),
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api }) =>
          event.target === el && api.lasso.pointerdown(event, el!),
      },
    }),
    photo: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => ({
        "data-selected": state.selected.includes(key) ? "" : undefined,
      }),
      live: live<PhotosState, any, { value: string }>(
        (state, { key, api }) => ({
          "data-lassoed": api.lasso.touches(state, key) ? "" : undefined,
        })
      ),
    }),
    lasso: slot({
      attrs: () => ({ "aria-hidden": "true" }),
      live: live<PhotosState, any, {}>((state, { api }) => ({
        style: api.lasso.style(state),
      })),
    }),
  }),
})

const parts = createParts(photos, { root: "div", photo: "div", lasso: "div" })

const Photos = parts.Root

const PhotosPhoto = parts.Photo

const PhotosLasso = parts.Lasso

const names = ["Beach", "Forest", "City", "Desert", "Lake", "Snow"]
const locked = ["City", "Lake"]

function Example() {
  return (
    <Photos locked={locked} aria-label="Photos">
      {names.map((name) => (
        <PhotosPhoto key={name} value={name}>
          {name}
          {locked.includes(name) && <span data-slot="badge">Locked</span>}
        </PhotosPhoto>
      ))}
      <PhotosLasso />
    </Photos>
  )
}

export {
  Example,
  photos,
  Photos,
  PhotosLasso,
  PhotosPhoto,
  type PhotosOptions,
  type PhotosState,
}
Drag from empty space across the photos. City and Lake stay unselected; a screen reader announces the selected count when you release.

Ranges of Cells

Select a Block

Focus and Keys shows how to navigate a grid. Add createCellRange when you also need to drag across cells, select whole rows or columns, or copy a block.

Keep a range: CellRange | null field in state, starting at null. Pass the existing grid behavior and its row and column counts:

const range = createCellRange(context, {
  key: "range",
  grid,
  rows: () => 4,
  cols: () => 5,
})

Return range from api, then update the existing cell slot:

selects: selects({ active: byCell, range: byCellRange }),
attrs: (state, { props, api }) => ({
  role: "gridcell",
  tabindex: api.grid.tabIndex(state, props),
  ...api.range.cellAttrs(state, props),
}),
on: {
  keydown: (event, { api }) => api.range.keydown(event),
  pointerdown: (event, { api, props }) => api.range.pointerdown(event, props),
},
bind: (el, { api, props }) => api.range.bind(el, props),

range.bind also binds grid focus, and range.keydown forwards ordinary navigation to the grid. You don't need to call both behaviors for the same event. byCellRange lets a range change reach only the cells that join or leave it and the cells along the sides that move.

import * as React from "react"
import {
  byCell,
  byCellRange,
  createCellRange,
  createGridFocus,
  definePrimitive,
  selects,
  type CellRange,
  type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"

type SheetState = { active: GridCell | null; range: CellRange | null }

type SheetOptions = { rows: number; cols: number }

type SheetSummaryState = {
  top: number
  left: number
  bottom: number
  right: number
}

const sheet = definePrimitive({
  name: "sheet",
  options: { rows: "number", cols: "number" },
  state: (_options: SheetOptions): SheetState => ({
    active: null,
    range: null,
  }),
  api: (context) => {
    const rows = () => context.options().rows
    const cols = () => context.options().cols
    const grid = createGridFocus<SheetState>(context, {
      key: "active",
      rows,
      cols,
    })
    return {
      grid,
      range: createCellRange<SheetState>(context, {
        key: "range",
        grid,
        rows,
        cols,
      }),
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        role: "grid",
        style: "user-select:none;touch-action:none",
      }),
    }),
    row: slot<{ id: string }>({
      props: { id: "string" },
      attrs: () => ({ role: "row" }),
    }),
    summary: slot<{}, SheetSummaryState>({
      state: (state, { api }) => {
        const bounds = state.active && api.range.bounds(state)
        return bounds ?? { top: -1, left: -1, bottom: -1, right: -1 }
      },
      attrs: () => ({ "aria-live": "polite" }),
    }),
    cell: slot<{ id: string; row: number; col: number }>({
      props: { row: "number", col: "number" },
      selects: selects({ active: byCell, range: byCellRange }),
      attrs: (state, { props, api }) => ({
        role: "gridcell",
        tabindex: api.grid.tabIndex(state, props),
        ...api.range.cellAttrs(state, props),
      }),
      on: {
        keydown: (event, { api }) => api.range.keydown(event),
        pointerdown: (event, { api, props }) =>
          api.range.pointerdown(event, props),
      },
      bind: (el, { api, props }) => api.range.bind(el, props),
    }),
  }),
})

const parts = createParts(sheet, { root: "div", row: "div", summary: "p" })

const Sheet = parts.Root

const SheetRow = parts.Row

function SheetCell({
  row,
  col,
  ...props
}: { row: number; col: number } & PartProps<"div">) {
  return usePart(sheet, "cell", { id: `${row}:${col}`, row, col }, props, "div")
}

const SheetSummary = parts.Summary

const values = [
  [4, 8, 15, 16],
  [23, 42, 7, 1],
  [9, 3, 12, 5],
]

function Example() {
  return (
    <Sheet rows={3} cols={4} aria-label="Numbers">
      {values.map((row, r) => (
        <SheetRow key={r} id={String(r)}>
          {row.map((value, c) => (
            <SheetCell key={c} row={r} col={c}>
              {value}
            </SheetCell>
          ))}
        </SheetRow>
      ))}
      <SheetSummary>
        {({ top, left, bottom, right }) => {
          let total = 0
          for (let row = Math.max(top, 0); row <= bottom; row++) {
            for (let col = Math.max(left, 0); col <= right; col++) {
              total += values[row][col]
            }
          }
          return `Total: ${total}`
        }}
      </SheetSummary>
    </Sheet>
  )
}

export {
  Example,
  sheet,
  Sheet,
  SheetCell,
  SheetRow,
  SheetSummary,
  type SheetOptions,
  type SheetState,
  type SheetSummaryState,
}
Drag across cells, or click one and hold Shift with the arrows. The total follows the selected range.

Focus stays on the active cell as the range grows. A move without Shift clears the range. The behavior also handles these shortcuts:

KeysAction
Shift+SpaceSelect the range's rows.
Control+SpaceSelect its columns.
Control+ASelect every cell.
EscapeClear the range.

cellAttrs marks the explicit range with aria-selected, data-selected, and data-range-edge. The last one lists the sides of the range a cell sits on, such as "top left", so you can draw an outline with [data-range-edge~="top"].

Selecting from Code

The same range can be changed from buttons or other controls. Row and column indexes start at zero:

range.selectColumns(1)
range.selectRows(0, 1)
range.select({ anchor: { row: 1, col: 1 }, end: { row: 2, col: 2 } })
range.selectAll()
range.select(null)

These calls change the range without moving focus. select(null) clears it.

import * as React from "react"
import {
  byCell,
  byCellRange,
  createCellRange,
  createChange,
  createGridFocus,
  definePrimitive,
  selects,
  type CellRange,
  type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"

type LedgerState = { active: GridCell | null; range: CellRange | null }

const values = [
  [4, 8, 15, 16],
  [23, 42, 7, 1],
  [9, 3, 12, 5],
]
const rows = values.length
const cols = values[0].length
const letters = "ABCD"

const nameOf = (row: number, col: number) => `${letters[col]}${row + 1}`

const shortcuts: Record<string, string> = {
  "column-b": "Column B",
  "rows-1-2": "Rows 1 and 2",
  block: "B2 to C3",
  all: "All",
}

const ledgerGrid = definePrimitive({
  name: "ledger-grid",
  state: (): LedgerState => ({ active: null, range: null }),
  api: (context) => {
    const change = createChange(context)
    const grid = createGridFocus<LedgerState>(context, {
      key: "active",
      rows: () => rows,
      cols: () => cols,
    })
    const range = createCellRange<LedgerState>(context, {
      key: "range",
      grid,
      rows: () => rows,
      cols: () => cols,
      set: (value, details) => change("range", value, details),
      message: ({ top, left, bottom, right }) =>
        `${nameOf(top, left)} to ${nameOf(bottom, right)} selected`,
    })
    return {
      grid,
      range,
      pick(shortcut: string) {
        if (shortcut === "column-b") {
          range.selectColumns(1)
        } else if (shortcut === "rows-1-2") {
          range.selectRows(0, 1)
        } else if (shortcut === "block") {
          range.select({ anchor: { row: 1, col: 1 }, end: { row: 2, col: 2 } })
        } else {
          range.selectAll()
        }
      },
    }
  },
  slots: (slot) => ({
    grid: slot({
      attrs: () => ({
        role: "grid",
        style: "user-select:none;touch-action:none",
      }),
    }),
    row: slot<{ id: string }>({
      props: { id: "string" },
      attrs: () => ({ role: "row" }),
    }),
    cell: slot<{ id: string; row: number; col: number }>({
      props: { row: "number", col: "number" },
      selects: selects({ active: byCell, range: byCellRange }),
      attrs: (state, { props, api }) => ({
        role: "gridcell",
        tabindex: api.grid.tabIndex(state, props),
        ...api.range.cellAttrs(state, props),
      }),
      on: {
        keydown: (event, { api }) => api.range.keydown(event),
        pointerdown: (event, { api, props }) =>
          api.range.pointerdown(event, props),
      },
      bind: (el, { api, props }) => api.range.bind(el, props),
    }),
    shortcut: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.pick(key),
    }),
    summary: slot<{}, { text: string }>({
      state: (state, { api }) => {
        const bounds = api.range.bounds(state)
        if (!bounds) {
          return { text: "Nothing selected" }
        }
        let big = 0
        for (let row = 0; row < rows; row++) {
          for (let col = 0; col < cols; col++) {
            if (
              api.range.contains(state, { row, col }) &&
              values[row][col] > 10
            ) {
              big++
            }
          }
        }
        const name = `${nameOf(bounds.top, bounds.left)}:${nameOf(bounds.bottom, bounds.right)}`
        return { text: `${name}, ${big} over 10` }
      },
    }),
  }),
})

const parts = createParts(ledgerGrid, {
  grid: "div",
  row: "div",
  shortcut: "button",
  summary: "p",
})

const LedgerGrid = parts.Root

const LedgerGridGrid = parts.Grid

const LedgerGridRow = parts.Row

function LedgerGridCell({
  row,
  col,
  ...props
}: { row: number; col: number } & PartProps<"div">) {
  return usePart(
    ledgerGrid,
    "cell",
    { id: `${row}:${col}`, row, col },
    props,
    "div"
  )
}

const LedgerGridShortcut = parts.Shortcut

const LedgerGridSummary = parts.Summary

function Example() {
  return (
    <LedgerGrid>
      <div data-slot="stack">
        <div data-slot="controls">
          {Object.entries(shortcuts).map(([value, label]) => (
            <LedgerGridShortcut data-size="sm" key={value} value={value}>
              {label}
            </LedgerGridShortcut>
          ))}
        </div>
        <LedgerGridGrid aria-label="Ledger">
          {values.map((row, r) => (
            <LedgerGridRow key={r} id={String(r)}>
              {row.map((value, c) => (
                <LedgerGridCell key={c} row={r} col={c}>
                  {value}
                </LedgerGridCell>
              ))}
            </LedgerGridRow>
          ))}
        </LedgerGridGrid>
        <LedgerGridSummary data-summary>{({ text }) => text}</LedgerGridSummary>
      </div>
    </LedgerGrid>
  )
}

export {
  Example,
  ledgerGrid,
  LedgerGrid,
  LedgerGridCell,
  LedgerGridGrid,
  LedgerGridRow,
  LedgerGridShortcut,
  LedgerGridSummary,
  type LedgerState,
}
Use the buttons to select a column, rows, a block or the whole grid. The summary names the range and counts values over 10.

range.bounds(state) gives the inclusive top, left, bottom and right indexes. With no explicit range, it falls back to the active cell, or returns null if there isn't one. range.contains(state, cell) uses the same rule when you need to total or count cells.

By default, range changes write to state directly. To report them through createChange, add set. You can also customize what screen readers hear after keyboard selection:

set: (value, details) => change("range", value, details),
message: ({ top, left, bottom, right }) =>
  `Rows ${top + 1} to ${bottom + 1}, columns ${left + 1} to ${right + 1} selected`,

For rows that aren't rendered yet, reveal works as it does in grid focus.

Copy and Paste

range.text(state, read) reads the selected cells as tab-separated text. Call it from the grid's copy handler:

copy(event: ClipboardEvent) {
  const state = context.store.get()
  const text = range.text(state, ({ row, col }) => state.cells[row][col])
  event.preventDefault()
  event.clipboardData?.setData("text/plain", text)
},

In the paste handler, textToCells turns clipboard text into rows:

const rows = textToCells(event.clipboardData?.getData("text/plain") ?? "")

Your handler prevents the default paste and writes those values into state, starting at the selection's top-left cell. Stop at the grid's edges. The demo's Code tab shows the full handler.

Console
import * as React from "react"
import {
  byCell,
  byCellRange,
  cellsToText,
  createCellRange,
  createGridFocus,
  definePrimitive,
  selects,
  textToCells,
  type CellRange,
  type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type PricesOptions = { onCopy?: (text: string) => void }

type PricesState = {
  active: GridCell | null
  range: CellRange | null
  cells: string[][]
}

const start = [
  ["Tea", "3", "12"],
  ["Coffee", "4", "9"],
  ["Cake", "5", "4"],
  ["", "", ""],
]
const rows = start.length
const cols = start[0].length

const prices = definePrimitive({
  name: "prices",
  options: { onCopy: "callback" },
  state: (_options: PricesOptions): PricesState => ({
    active: null,
    range: null,
    cells: start,
  }),
  api: (context) => {
    const { store } = context
    const grid = createGridFocus<PricesState>(context, {
      key: "active",
      rows: () => rows,
      cols: () => cols,
    })
    const range = createCellRange<PricesState>(context, {
      key: "range",
      grid,
      rows: () => rows,
      cols: () => cols,
    })
    return {
      grid,
      range,
      copy(event: ClipboardEvent) {
        const state = store.get()
        const text = range.text(state, ({ row, col }) => state.cells[row][col])
        event.preventDefault()
        event.clipboardData?.setData("text/plain", text)
        context.options().onCopy?.(text)
      },
      paste(event: ClipboardEvent) {
        const state = store.get()
        const bounds = range.bounds(state)
        if (!bounds) {
          return
        }
        event.preventDefault()
        const pasted = textToCells(
          event.clipboardData?.getData("text/plain") ?? ""
        )
        const cells = state.cells.map((row) => [...row])
        pasted.forEach((values, r) =>
          values.forEach((value, c) => {
            const row = bounds.top + r
            const col = bounds.left + c
            if (row < rows && col < cols) {
              cells[row][col] = value
            }
          })
        )
        store.set({ cells })
      },
      copyAll() {
        const text = cellsToText(store.get().cells)
        context.options().onCopy?.(text)
        navigator.clipboard?.writeText(text).catch(() => {})
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    grid: slot({
      attrs: () => ({
        role: "grid",
        style: "user-select:none;touch-action:none",
      }),
      on: {
        copy: (event, { api }) => api.copy(event as ClipboardEvent),
        paste: (event, { api }) => api.paste(event as ClipboardEvent),
      },
    }),
    row: slot<{ id: string }>({
      props: { id: "string" },
      attrs: () => ({ role: "row" }),
    }),
    cell: slot<{ id: string; row: number; col: number }, { text: string }>({
      props: { row: "number", col: "number" },
      selects: selects({ active: byCell, range: byCellRange }),
      state: (state, { props }) => ({
        text: state.cells[props.row][props.col],
      }),
      attrs: (state, { props, api }) => ({
        role: "gridcell",
        tabindex: api.grid.tabIndex(state, props),
        ...api.range.cellAttrs(state, props),
      }),
      on: {
        keydown: (event, { api }) => api.range.keydown(event),
        pointerdown: (event, { api, props }) =>
          api.range.pointerdown(event, props),
      },
      bind: (el, { api, props }) => api.range.bind(el, props),
    }),
    copyAll: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.copyAll(),
    }),
  }),
})

const parts = createParts(prices, {
  root: "div",
  grid: "div",
  row: "div",
  copyAll: "button",
})

const Prices = parts.Root

const PricesGrid = parts.Grid

const PricesRow = parts.Row

function PricesCell({
  row,
  col,
  ...props
}: { row: number; col: number } & PartProps<"div", { text: string }>) {
  return usePart(
    prices,
    "cell",
    { id: `${row}:${col}`, row, col },
    props,
    "div"
  )
}

const PricesCopyAll = parts.CopyAll

function Example() {
  const log = useLog()
  return (
    <Prices onCopy={(text: string) => log(`Copied ${JSON.stringify(text)}`)}>
      <div data-slot="stack">
        <div data-slot="controls">
          <PricesCopyAll data-size="sm">Copy Whole Sheet</PricesCopyAll>
        </div>
        <PricesGrid aria-label="Prices">
          {start.map((cells, r) => (
            <PricesRow key={r} id={String(r)}>
              {cells.map((_, c) => (
                <PricesCell key={c} row={r} col={c}>
                  {({ text }) => text}
                </PricesCell>
              ))}
            </PricesRow>
          ))}
        </PricesGrid>
      </div>
    </Prices>
  )
}

export {
  Example,
  prices,
  Prices,
  PricesCell,
  PricesCopyAll,
  PricesGrid,
  PricesRow,
  type PricesOptions,
  type PricesState,
}
Select cells and copy with Control or Command+C. Click a destination cell and paste with Control or Command+V.

cellsToText(rows) works in the other direction. Use it to copy an entire sheet, even when only part of it is selected.

API Reference

Item Selection Options

keyKey
The state key that holds the selected keys, a string[].
Example: Choose One
set(value: string[], details: Why<Reason>) => boolean
Asks for a new selection and returns whether it went through. Pass the primitive's change, so on<Key>Change, cancel() and controlled values work: set: (value, details) => change("value", value, details).
Example: Choose One
order() => readonly string[]
Every key in list order, for ranges and select all, like the visible items' keys.
Example: Choose One
multiple?(() => boolean)
Whether more than one item can be selected. Defaults to false.
Example: Choose Many
selectable?((key: string) => boolean)
Whether a key can be chosen, toggled, or added to a range. Replacing the selection can still remove it. Defaults to every item.
Example: Items That Can't Be Selected

Rectangle Selection Options

slotstring
The slot whose parts the rectangle selects.
Example: Draw a Rectangle
draftkeyof State & string
The state key that holds the LassoDraft while one is drawn. Start it at null, and list it in live.
Example: Draw a Rectangle
selected() => string[]
The selection now, which Shift adds to.
Example: Draw a Rectangle
set(keys: string[], details: { reason: "lasso"; event?: Event; }) => boolean
Asks for a new selection. Pass the primitive's change.
Example: Draw a Rectangle
selectable?((key: string) => boolean)
Whether an item can be selected. Defaults to every item.
Example: Items It Skips
message?((count: number) => string)
What screen readers hear when the rectangle lifts. Defaults to English.
Example: Items It Skips

Cell Range Options

keykeyof State & string
The state key that holds the CellRange, or null when only the active cell is selected.
Example: Select a Block
gridobject
The grid's focus, which keeps the active cell.
Example: Select a Block
rows() => number
How many rows the range can reach.
Example: Select a Block
cols() => number
How many columns.
Example: Select a Block
reveal?((cell: GridCell) => void)
Shows a cell the range grows to that may not be rendered, like createGridFocus's reveal.
Example: Rows That Aren't Rendered
set?((range: CellRange | null, details: { reason: CellRangeReason; event?: Event; }) => boolean)
Asks for a new range and returns whether it went through. Defaults to writing the state; pass the primitive's change to report it.
Example: Selecting from Code
message?((bounds: CellBounds) => string)
What screen readers hear after the range changes from the keyboard. Defaults to English.
Example: Selecting from Code