SelectionMode

Pick several things at once, on a screen or in a sheet.

Alpha — the API is still moving. Expect it to change in a minor release.

Pick several things at once — messages to archive, people to share with, colours to apply, files to move.

It comes two ways round. On a screen it is a mode: the list is there to be read, and a long press turns it into one you can pick from. In a sheet it is a picker: SelectionMode.Sheet was opened in order to choose something, so it is choosing from the moment it appears, with the actions in the sheet's footer.

SelectionMode.Item wraps whatever you put in it rather than replacing it — a row, an avatar, a colour swatch, a slide thumbnail — so one component covers all of those without a prop for each.

For picking one thing, use Select or RadioGroup. For one row and the things you can do to it, use Swipe.

Installation

SelectionMode ships with the library — no separate install.

import { SelectionMode, Item, Avatar, Text, Slider } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add selection-mode

Usage

const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);

<SelectionMode
  values={people.map((person) => person.id)}
  selected={selected}
  onSelectedChange={setSelected}
>
  <SelectionMode.Sheet open={open} onOpenChange={setOpen} title="Share with">
    {people.map((person) => (
      <SelectionMode.Item key={person.id} value={person.id}>
        <Item>
          <Item.Media>
            <Avatar fallback={person.initials} />
          </Item.Media>
          <Item.Content>
            <Item.Title>{person.name}</Item.Title>
            <Item.Description>{person.handle}</Item.Description>
          </Item.Content>
        </Item>
      </SelectionMode.Item>
    ))}

    <SelectionMode.Bar>
      <SelectionMode.Action icon={<SendIcon size={20} />} onPress={share}>
        Send
      </SelectionMode.Action>
    </SelectionMode.Bar>
  </SelectionMode.Sheet>
</SelectionMode>

Composition

<SelectionMode>
  {/* on a screen */}
  <SelectionMode.Header />
  <SelectionMode.Item>
    <SelectionMode.Indicator />
    {/* your item */}
  </SelectionMode.Item>
  <SelectionMode.Bar>
    <SelectionMode.Action />
  </SelectionMode.Bar>

  {/* …or in a sheet */}
  <SelectionMode.Sheet>
    <SelectionMode.Group>
      <SelectionMode.Item />
    </SelectionMode.Group>
    <SelectionMode.Bar />
  </SelectionMode.Sheet>
</SelectionMode>

Every part except the root reads the selection from context, so they all throw outside a SelectionMode rather than rendering something inert. useSelectionMode() is the same context, for a header or a count of your own.

Examples

Anything, not just rows

An item is a wrapper, so what it holds is yours. Group them in a card for rows, or pass columns for a grid of things recognised by sight — a swatch, a thumbnail, a slide. indicator="ring" draws the selection around the item instead of beside it, because a circle next to a colour is a second thing to look at.

<SelectionMode.Sheet open={open} onOpenChange={setOpen} title="Palette">
  <SelectionMode.Group columns={5} gap={14}>
    {palette.map((color) => (
      <SelectionMode.Item key={color.id} value={color.id} indicator="ring">
        <View
          style={{ backgroundColor: color.hex, aspectRatio: 1 }}
          className="w-full rounded-full"
        />
      </SelectionMode.Item>
    ))}
  </SelectionMode.Group>

  <SelectionMode.Bar>
    <SelectionMode.Action icon={<CheckIcon size={20} />} onPress={apply}>
      Apply
    </SelectionMode.Action>
  </SelectionMode.Bar>
</SelectionMode.Sheet>

On a screen, as a mode

A list that is there to be read, that you occasionally act on several rows of. A long press enters and picks the row that was pressed; the header says the mode is on and is the way out of it; the bar sits against the bottom edge. Leaving clears the selection, so it cannot come back the next time — control selected yourself to keep it.

<SelectionMode
  values={messages.map((message) => message.id)}
  selected={selected}
  onSelectedChange={setSelected}
>
  <SelectionMode.Header title="Choose" />

  <FlashList
    data={messages}
    keyExtractor={(message) => message.id}
    renderItem={({ item }) => (
      <SelectionMode.Item value={item.id} onPress={() => open(item)}>
        <Item>…</Item>
      </SelectionMode.Item>
    )}
    contentContainerClassName="pb-24"
  />

  <SelectionMode.Bar inset={insets.bottom}>
    <SelectionMode.Action icon={<TrashIcon size={20} />} destructive exitOnPress onPress={remove}>
      Delete
    </SelectionMode.Action>
  </SelectionMode.Bar>
</SelectionMode>

Select all, and a limit

Pass values and the header can pick everything at once. max caps it — a row that would go over the cap does not toggle on, and select all stops at the limit rather than refusing, because somebody who asked for all of them and can have twenty wants the twenty.

<SelectionMode values={slides.map((slide) => slide.id)} max={20}>
  <SelectionMode.Sheet open={open} onOpenChange={setOpen} title="Slides" />
</SelectionMode>

An item that cannot be picked

disabled keeps an item out of the selection entirely — it will not start a mode, and it cannot be picked once one is running. It does not disable the row's ordinary onPress, so a section header, advert, or "load more" action stays usable even while other rows are being selected.

<SelectionMode.Item value={row.id} disabled={row.type === 'header'}>
  {/* … */}
</SelectionMode.Item>

A bar that floats instead

On a screen, placement="floating" lifts the bar off the edge into a rounded card — for a list that does not run to the bottom of the screen. Flush is the default because it does not take width away from the actions. In a sheet the bar is the footer and this does not apply.

<SelectionMode.Bar placement="floating" inset={insets.bottom}>
  <SelectionMode.Action icon={<TrashIcon size={20} />} destructive onPress={remove}>
    Delete
  </SelectionMode.Action>
</SelectionMode.Bar>

A strip of swatches, and a control under it

horizontal is for small things picked by sight when something else has to share the sheet with them. A grid of the same swatches claims as many rows as it needs and pushes whatever follows off the bottom.

label names the strip. A row of unlabelled circles is a row a reader has to work out the purpose of, and a screen reader has nothing at all to announce it by.

<SelectionMode.Sheet open={open} onOpenChange={setOpen} title="Palette">
  <SelectionMode.Group horizontal label="Colour" itemWidth={44} gap={14}>
    {colors.map((color) => (
      <SelectionMode.Item key={color.id} value={color.id} indicator="ring">
        <View style={{ backgroundColor: color.hex, aspectRatio: 1 }} className="w-full rounded-full" />
      </SelectionMode.Item>
    ))}
  </SelectionMode.Group>

  <Slider label="Opacity" showValue value={opacity} onValueChange={setOpacity} />

  <SelectionMode.Bar>
    <SelectionMode.Action icon={<CheckIcon size={20} />} onPress={() => setOpen(false)}>
      Apply
    </SelectionMode.Action>
  </SelectionMode.Bar>
</SelectionMode.Sheet>

Variants

compact

  • true
<SelectionMode compact>…</SelectionMode>

destructive

  • true
<SelectionMode destructive>…</SelectionMode>

surface

  • screen (default)
  • sheet
<SelectionMode surface="screen">…</SelectionMode>
<SelectionMode surface="sheet">…</SelectionMode>

placement

  • bar (default)
  • floating
<SelectionMode placement="bar">…</SelectionMode>
<SelectionMode placement="floating">…</SelectionMode>

API Reference

SelectionMode

PropTypeDefaultDescription
classNamestring—
valuesstring[]—Every value that can be picked, in list order. Only "select all" and the "n of m" in the header need it — picking rows one at a time works without it. Give it the same ids you give the list.
activeboolean—Controlled selection mode. Leave it out and a long press turns it on.
defaultActivebooleanfalseWhether selection mode starts on.
onActiveChange(active: boolean) => void—
selectedstring[]—Controlled selection.
defaultSelectedstring[]—
onSelectedChange(selected: string[]) => void—
maxnumber—The most that can be picked at once. A row that would go over it does not toggle on, and "select all" stops at the limit rather than refusing. Leave it out for no limit.
hapticsbooleanfalseA tick when a row is picked and when the mode is entered. Off by default — needs the optional expo-haptics, and is silent without it.

SelectionMode.Indicator

PropTypeDefaultDescription
classNamestring—
valuestring—Which row this stands for. Defaults to the row it is inside.

SelectionMode.Item

PropTypeDefaultDescription
classNamestring—
valuestring—This row's id. What ends up in selected.
onPress() => void—What the row does whenever selection does not own its press.
disabledbooleanfalseStop this row entering selection mode, and being picked once in it. Its ordinary onPress still runs, including while selection is active. For a header row, an advert, a "load more" — anything in the list that is not one of the things being chosen between.
alwaysShowIndicatorbooleanfalseDraw the circle without waiting for the mode.
indicator'leading' | 'ring' | 'none''leading'How being picked is drawn. leading puts the circle in front of the item, which is what a row wants. ring draws a ring around whatever you gave it instead — for a swatch, a thumbnail or a photo, where a circle beside it would be a second thing to look at and the item itself can carry the state. none draws nothing and leaves it to you; read useSelectionMode().isSelected.

SelectionMode.Group

PropTypeDefaultDescription
classNamestring—
columnsnumber—Lay the items out in a grid this many across instead of stacking them. For things recognised by sight rather than read — swatches, thumbnails, slides. A grid of six colours is one glance; the same six as rows is a scroll. Ignored when horizontal is set.
horizontalbooleanfalseLay the items out in one row that scrolls sideways. For a strip of small things next to other controls — swatches above a slider, filters above a list. A grid of the same items claims as many rows as it needs and pushes everything below it off the sheet; a strip costs one row whatever the count. Wins over columns, which asks for the opposite arrangement.
itemWidthnumber44How wide each item is in a horizontal strip, in points.
gapnumber12Space between items in a grid or a strip, in points.
labelstring—A caption above the items, on the leading edge. Worth setting on anything picked by sight. A strip of colours with nothing in front of it is a row of circles the reader has to work out the purpose of, and a screen reader has nothing at all to announce it by — so this is also the group's accessibility label.
labelClassNamestring—Extra classes for that caption.
separatorsbooleantrueHairlines between stacked items. On by default; off in a grid or a strip.

SelectionMode.Header

PropTypeDefaultDescription
classNamestring—
titlestring'Select'The word in front of the count.
hideSelectAllbooleanfalseHide the select-all control, for a list where picking everything is wrong.

SelectionMode.Bar

PropTypeDefaultDescription
classNamestring—
insetnumber0Room under the actions, in points — your safe-area inset. A bar against the bottom edge sits over the home indicator on a phone that has one, and an action under a home indicator is an action that takes two tries. floating uses it as the gap on all four sides instead.
showWhenEmptybooleanfalseKeep the bar up with nothing picked. Off by default: every action on it needs something to act on, and a row of buttons that all refuse is worse than a row that is not there yet.

SelectionMode.Action

PropTypeDefaultDescription
classNamestring—
iconReactNode—The glyph above the label.
onPress(selected: string[]) => void—What it does. Handed the selection, so the common case needs no other wiring — and leaving the mode afterwards is up to you, because whether the list still makes sense depends on what you did to it.
exitOnPressbooleanfalseLeave selection mode after the action runs.
disabledbooleanfalse
labelClassNamestring—Extra classes for the label.

SelectionMode.Sheet

PropTypeDefaultDescription
classNamestring—
openboolean—Controlled open state of the sheet.
defaultOpenboolean—
onOpenChange(open: boolean) => void—
titlestring'Select'The word in front of the count.
hideSelectAllbooleanfalseHide the select-all control.
size'auto' | 'half' | 'full''full'How tall the sheet opens. full by default, and deliberately not auto. A sheet that sizes to its content gives its scrolling body no height to fill, and a list inside a box of no height draws nothing — which looks like an empty sheet rather than like a missing style. Full rather than half because a picker spends a header and a footer before it draws a single row. At half a screen that leaves four or five rows for the thing the sheet was opened to do, and the reader scrolls a list that would have fitted. Pass half for a sheet of two or three choices.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

The sheet is half-height by default

Not auto. A sheet that sizes to its content gives its scrolling body no height to fill, and a list in a box of no height draws nothing — which reads as an empty sheet rather than as a missing style. Pass size="full" for a long list, or size="auto" only when the sheet really is a handful of rows.

Group the items

SelectionMode.Group is the rounded card with rules between its rows. One card of options reads as a set; the same rows loose on the sheet background read as a list that has not finished loading.

Which presentation

Use the sheet when choosing is the whole reason the surface exists — a share sheet, a palette, an attachment picker. Use the on-screen mode when the list has a life of its own and picking is something you occasionally do to it.

They are the same component and the same state; only the presentation differs. In a sheet there is no long press and no cancel button — the sheet dismisses itself — and the bar sits in the footer rather than over the list.

The circle is round for a reason

A square box is a form control somebody is filling in; a round one is a thing they are picking out of a set. Checkbox is the first of those and stays square.

On a screen, pad the bottom of your list

SelectionMode.Bar is drawn over the list rather than under it, because the list is as long as it is and a bar in the flow would be somewhere off the end of it. Nothing here can work out how tall your list is, so give it bottom padding — contentContainerClassName="pb-24" — or the last row sits under the bar forever.

Pass inset with your safe-area bottom inset, or the bar sits over the home indicator.

The bar is hidden until something is picked

Every action on it needs something to act on, and a row of buttons that all refuse is worse than a row that has not appeared yet. Pass showWhenEmpty if you would rather it were there the whole time.

Actions are handed the selection

onPress receives the picked values, so the common case needs no other wiring. Leaving afterwards is exitOnPress, and it is opt-in because whether the list still makes sense depends on what you just did to it — deleting means leaving, marking read might not.

Selection is a set of values

Ids, never indices or elements. A list that reorders, pages in more items or drops one underneath the reader would invalidate anything positional, and ids are also the shape the action at the end needs.

Give it a height

The root fills a height it is offered and takes its content's height when there is none. What it cannot do is fill a parent that has no height itself — put it in a container with flex-1, or give it a fixed height, the same as any list. In a sheet the sheet handles this.

Public exports

Values: SelectionMode, useSelectionMode

Types: SelectionModeProps, SelectionModeItemProps, SelectionModeIndicatorProps, SelectionModeHeaderProps, SelectionModeBarProps, SelectionModeActionProps

On this page