# Selection List

A list with an edit mode for selecting multiple rows and acting on them from a floating toolbar.

Selection List is an open-source interaction from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/interactions/selection-list

**React**

**Example**

```tsx
import { CaretRightIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
  useSelectionList,
} from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"

const initialNotes = [
  { id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: "books", title: "Book list", preview: "Three novels for the summer" },
  { id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]

type Note = (typeof initialNotes)[number]

function NoteRows({
  notes,
  onOpen,
}: {
  notes: Note[]
  onOpen: (note: Note) => void
}) {
  return (
    <div className="overflow-hidden rounded-2xl border">
      {notes.map((note) => (
        <SelectionListItem
          key={note.id}
          value={note.id}
          className="not-first:border-t"
        >
          <button
            type="button"
            onClick={() => onOpen(note)}
            className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
          >
            <span className="flex min-w-0 flex-1 flex-col">
              <span className="truncate text-base font-semibold">
                {note.title}
              </span>
              <span className="truncate text-sm text-muted-foreground">
                {note.preview}
              </span>
            </span>
            <CaretRightIcon
              aria-hidden
              className="size-4 shrink-0 text-muted-foreground transition-opacity duration-200 rtl:rotate-180 group-data-editing/selection-list:opacity-0"
            />
          </button>
        </SelectionListItem>
      ))}
    </div>
  )
}

function SelectAllButton() {
  const { selected, selectAll, clear } = useSelectionList()
  const all = selected.length > 0

  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4"
      onClick={all ? clear : selectAll}
    >
      {all ? "Deselect All" : "Select All"}
    </Button>
  )
}

function DeleteButton({ onDelete }: { onDelete: (ids: string[]) => void }) {
  const { selected, setEditing } = useSelectionList()

  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4 text-destructive disabled:opacity-50"
      disabled={selected.length === 0}
      onClick={() => {
        onDelete(selected)
        setEditing(false)
      }}
    >
      Delete
    </Button>
  )
}

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Tap Select to choose notes.")

  return {
    notes,
    log,
    setLog,
    remove: (ids: string[]) => {
      setNotes((current) => current.filter((note) => !ids.includes(note.id)))
      setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
    },
    reset: () => {
      setNotes(initialNotes)
      setLog("Tap Select to choose notes.")
    },
  }
}

function NotesExample() {
  const { notes, log, setLog, remove, reset } = useNotes()

  return (
    <SelectionList className="mx-auto flex w-full max-w-md flex-col gap-3 pb-24">
      <div className="flex items-center justify-between">
        <h2 className="text-3xl font-bold tracking-tight">Notes</h2>
        <SelectionListTrigger />
      </div>
      <NoteRows
        notes={notes}
        onOpen={(note) => setLog(`Opened ${note.title}`)}
      />
      <div className="flex items-center justify-between gap-3">
        <p role="status" className="text-sm text-muted-foreground">
          {log}
        </p>
        <Button size="sm" variant="secondary" onClick={reset}>
          Reset
        </Button>
      </div>
      <SelectionListBar aria-label="Selection actions">
        <SelectAllButton />
        <SelectionListCount />
        <DeleteButton onDelete={remove} />
      </SelectionListBar>
    </SelectionList>
  )
}

export default function SelectionListDemo() {
  return <NotesExample />
}
```

`SelectionList` recreates the iOS Select mode for lists. Pressing `SelectionListTrigger` switches into editing: each `SelectionListItem` slides in a round checkmark indicator and becomes a checkbox, and `SelectionListBar` rises from the bottom of the screen as a floating toolbar. The row content is made `inert` while editing, so taps toggle selection instead of opening the row. Leaving editing clears the selection.

The trigger is a suiss/ui `Button` that reads `Select` or `Done` by default, and `SelectionListCount` shows how many rows are selected. Use it for lists where people manage several items at once, such as deleting notes or archiving mail.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/selection-list
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions selection-list
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button).

3. Copy the source into `components/interactions/selection-list.tsx`:

```tsx title="components/interactions/selection-list.tsx"
"use client"

import { CheckIcon } from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type SelectionListContextProps = {
  editing: boolean
  setEditing: (editing: boolean) => void
  selected: string[]
  setSelected: (selected: string[]) => void
  toggle: (value: string, range: boolean) => void
  selectAll: () => void
  clear: () => void
}

const SelectionListContext =
  React.createContext<SelectionListContextProps | null>(null)

function useSelectionList() {
  const context = React.useContext(SelectionListContext)

  if (!context) {
    throw new Error("useSelectionList must be used within a <SelectionList />")
  }

  return context
}

function itemValues(root: HTMLElement | null) {
  return Array.from(
    root?.querySelectorAll<HTMLElement>("[data-slot=selection-list-item]") ??
      [],
    (item) => item.dataset.value ?? ""
  )
}

function SelectionList({
  editing: editingProp,
  defaultEditing = false,
  onEditingChange,
  value,
  defaultValue = [],
  onValueChange,
  className,
  ref,
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue"> & {
  editing?: boolean
  defaultEditing?: boolean
  onEditingChange?: (editing: boolean) => void
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}) {
  const [uncontrolledEditing, setUncontrolledEditing] =
    React.useState(defaultEditing)
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const editing = editingProp ?? uncontrolledEditing
  const selected = value ?? uncontrolledValue
  const rootRef = React.useRef<HTMLDivElement>(null)
  const anchorRef = React.useRef<string | null>(null)

  const setSelected = React.useCallback(
    (next: string[]) => {
      if (value === undefined) setUncontrolledValue(next)
      onValueChange?.(next)
    },
    [value, onValueChange]
  )

  const setEditing = React.useCallback(
    (next: boolean) => {
      if (editingProp === undefined) setUncontrolledEditing(next)
      onEditingChange?.(next)
      if (!next) {
        anchorRef.current = null
        setSelected([])
      }
    },
    [editingProp, onEditingChange, setSelected]
  )

  const toggle = (item: string, range: boolean) => {
    const values = itemValues(rootRef.current)
    const anchor = anchorRef.current
    if (range && anchor !== null && values.includes(anchor)) {
      const from = values.indexOf(anchor)
      const to = values.indexOf(item)
      const span = values.slice(Math.min(from, to), Math.max(from, to) + 1)
      setSelected([...new Set([...selected, ...span])])
    } else {
      setSelected(
        selected.includes(item)
          ? selected.filter((entry) => entry !== item)
          : [...selected, item]
      )
    }
    anchorRef.current = item
  }

  React.useEffect(() => {
    if (!editing) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !event.defaultPrevented) setEditing(false)
    }
    document.addEventListener("keydown", onKeyDown)
    return () => document.removeEventListener("keydown", onKeyDown)
  }, [editing, setEditing])

  const selectAll = () => setSelected(itemValues(rootRef.current))
  const clear = () => setSelected([])

  return (
    <SelectionListContext.Provider
      value={{
        editing,
        setEditing,
        selected,
        setSelected,
        toggle,
        selectAll,
        clear,
      }}
    >
      <div
        ref={(node) => {
          rootRef.current = node
          if (typeof ref === "function") return ref(node)
          if (ref) ref.current = node
        }}
        data-slot="selection-list"
        data-editing={editing ? "" : undefined}
        className={cn("group/selection-list", className)}
        {...props}
      />
    </SelectionListContext.Provider>
  )
}

function SelectionListTrigger({
  className,
  children,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { editing, setEditing } = useSelectionList()

  return (
    <Button
      variant="plain"
      data-slot="selection-list-trigger"
      aria-pressed={editing}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) setEditing(!editing)
      }}
      className={cn("font-semibold aria-pressed:bg-transparent", className)}
      {...props}
    >
      {children ?? (editing ? "Done" : "Select")}
    </Button>
  )
}

function SelectionListItem({
  value,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & { value: string }) {
  const { editing, selected, toggle } = useSelectionList()
  const checked = selected.includes(value)
  const contentRef = React.useRef<HTMLDivElement>(null)
  const [label, setLabel] = React.useState<string>()

  React.useLayoutEffect(() => {
    if (editing)
      setLabel(
        contentRef.current?.innerText.replace(/\s+/g, " ").trim() || undefined
      )
  }, [editing])

  const shared = {
    "data-slot": "selection-list-item",
    "data-value": value,
    "data-checked": checked ? "" : undefined,
    className: cn(
      "flex items-center outline-none transition-colors duration-200 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset data-checked:bg-accent",
      editing && "cursor-pointer select-none",
      className
    ),
    children: (
      <>
        <span
          aria-hidden
          className="flex w-0 shrink-0 items-center overflow-hidden opacity-0 transition-[width,opacity] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-editing/selection-list:w-10 group-data-editing/selection-list:opacity-100 motion-reduce:transition-none"
        >
          <span
            data-slot="selection-list-indicator"
            className="ms-4 flex size-5.5 shrink-0 scale-50 items-center justify-center rounded-full border-[1.5px] border-muted-foreground transition-[scale,background-color,border-color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-editing/selection-list:scale-100 in-data-checked:border-primary in-data-checked:bg-primary motion-reduce:transition-none"
          >
            <CheckIcon
              className="size-3.5 scale-0 text-primary-foreground transition-transform duration-200 in-data-checked:scale-100"
            />
          </span>
        </span>
        <div
          ref={contentRef}
          data-slot="selection-list-item-content"
          inert={editing}
          className="flex min-w-0 flex-1 items-center"
        >
          {children}
        </div>
      </>
    ),
  }

  if (!editing) return <div {...props} {...shared} />

  return (
    <div
      {...props}
      {...shared}
      role="checkbox"
      aria-checked={checked}
      aria-label={props["aria-label"] ?? label}
      tabIndex={0}
      onClick={(event) => {
        props.onClick?.(event)
        toggle(value, event.shiftKey)
      }}
      onKeyDown={(event) => {
        props.onKeyDown?.(event)
        if (event.key === " " || event.key === "Enter") {
          event.preventDefault()
          toggle(value, event.shiftKey)
        }
      }}
    />
  )
}

function SelectionListBar({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const { editing } = useSelectionList()

  return (
    <div
      role="toolbar"
      data-slot="selection-list-bar"
      data-closed={editing ? undefined : ""}
      inert={!editing}
      className={cn(
        "fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-40 mx-auto flex h-13 max-w-md items-center justify-between gap-2 rounded-full bg-card/80 px-1.5 text-card-foreground shadow-xl ring-1 ring-border backdrop-blur-3xl backdrop-saturate-180 transition-[translate,opacity,scale,visibility] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] transition-discrete data-closed:invisible data-closed:opacity-0 data-closed:duration-300 motion-safe:data-closed:translate-y-[calc(100%+1rem)] motion-safe:data-closed:scale-95 dark:bg-muted/75",
        className
      )}
      {...props}
    />
  )
}

function SelectionListCount({
  className,
  children,
  ...props
}: React.ComponentProps<"span">) {
  const { selected } = useSelectionList()

  return (
    <span
      data-slot="selection-list-count"
      aria-live="polite"
      className={cn(
        "min-w-0 flex-1 truncate text-center text-sm font-medium tabular-nums",
        className
      )}
      {...props}
    >
      {children ??
        (selected.length === 0
          ? "Select items"
          : `${selected.length} selected`)}
    </span>
  )
}

export {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
  useSelectionList,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { SelectionList, SelectionListTrigger, SelectionListItem, SelectionListBar, SelectionListCount } from "@/components/interactions/selection-list"
```

```tsx
<NotesExample />
```

- Place `SelectionListTrigger`, the `SelectionListItem` rows, and `SelectionListBar` inside `SelectionList`. Put your own action buttons and `SelectionListCount` inside the bar.
- Editing and selection are each uncontrolled by default. Pass `editing` and `onEditingChange`, or `value` and `onValueChange`, to control them.
- `useSelectionList` returns `editing`, `setEditing`, `selected`, `setSelected`, `selectAll`, and `clear` for building actions such as Select All or Delete.
- Shift-clicking a row, or pressing `Shift` with `Enter` or `Space`, selects the range from the previously toggled row.
- `Escape` leaves editing.

## Examples

### Icon actions

A controlled selection drives icon-only toolbar actions that stay disabled until a row is picked.

**Example**

```tsx
import {
  ArchiveIcon,
  CaretRightIcon,
  FolderSimpleIcon,
  TrashIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
} from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"

const initialNotes = [
  { id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: "books", title: "Book list", preview: "Three novels for the summer" },
  { id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]

type Note = (typeof initialNotes)[number]

function NoteRows({
  notes,
  onOpen,
}: {
  notes: Note[]
  onOpen: (note: Note) => void
}) {
  return (
    <div className="overflow-hidden rounded-2xl border">
      {notes.map((note) => (
        <SelectionListItem
          key={note.id}
          value={note.id}
          className="not-first:border-t"
        >
          <button
            type="button"
            onClick={() => onOpen(note)}
            className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
          >
            <span className="flex min-w-0 flex-1 flex-col">
              <span className="truncate text-base font-semibold">
                {note.title}
              </span>
              <span className="truncate text-sm text-muted-foreground">
                {note.preview}
              </span>
            </span>
            <CaretRightIcon
              aria-hidden
              className="size-4 shrink-0 text-muted-foreground transition-opacity duration-200 rtl:rotate-180 group-data-editing/selection-list:opacity-0"
            />
          </button>
        </SelectionListItem>
      ))}
    </div>
  )
}

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Tap Select to choose notes.")

  return {
    notes,
    log,
    setLog,
    remove: (ids: string[]) => {
      setNotes((current) => current.filter((note) => !ids.includes(note.id)))
      setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
    },
    reset: () => {
      setNotes(initialNotes)
      setLog("Tap Select to choose notes.")
    },
  }
}

function MailExample() {
  const { notes, log, setLog, remove, reset } = useNotes()
  const [selected, setSelected] = React.useState<string[]>([])

  const act = (verb: string) => {
    setLog(`${verb} ${selected.join(", ")}`)
  }

  return (
    <SelectionList
      value={selected}
      onValueChange={setSelected}
      className="mx-auto flex w-full max-w-md flex-col gap-3 pb-24"
    >
      <div className="flex items-center justify-between">
        <h2 className="text-3xl font-bold tracking-tight">Inbox</h2>
        <SelectionListTrigger />
      </div>
      <NoteRows
        notes={notes}
        onOpen={(note) => setLog(`Opened ${note.title}`)}
      />
      <div className="flex items-center justify-between gap-3">
        <p role="status" className="text-sm text-muted-foreground">
          {log}
        </p>
        <Button size="sm" variant="secondary" onClick={reset}>
          Reset
        </Button>
      </div>
      <SelectionListBar aria-label="Selection actions">
        <Button
          variant="plain"
          size="icon-lg"
          aria-label="Archive"
          disabled={selected.length === 0}
          onClick={() => act("Archived")}
        >
          <ArchiveIcon className="size-5.5" />
        </Button>
        <Button
          variant="plain"
          size="icon-lg"
          aria-label="Move"
          disabled={selected.length === 0}
          onClick={() => act("Moved")}
        >
          <FolderSimpleIcon className="size-5.5" />
        </Button>
        <SelectionListCount />
        <Button
          variant="plain"
          size="icon-lg"
          aria-label="Delete"
          className="text-destructive disabled:opacity-50"
          disabled={selected.length === 0}
          onClick={() => {
            remove(selected)
            setSelected([])
          }}
        >
          <TrashIcon className="size-5.5" />
        </Button>
      </SelectionListBar>
    </SelectionList>
  )
}

export default function SelectionListIconActions() {
  return <MailExample />
}
```

## API Reference

### SelectionList

The root that holds the editing state and the selected values.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `editing` | `boolean` | - | Whether the list is in editing mode, for controlled use. |
| `defaultEditing` | `boolean` | `false` | Whether the list starts in editing mode when uncontrolled. |
| `onEditingChange` | `(editing: boolean) => void` | - | Called when editing mode turns on or off. |
| `value` | `string[]` | - | The selected row values, for controlled use. |
| `defaultValue` | `string[]` | `[]` | The rows selected initially when uncontrolled. |
| `onValueChange` | `(value: string[]) => void` | - | Called with the new array of selected values. |

Also accepts the props of `React.ComponentProps<"div">`.

### SelectionListTrigger

The button that enters and leaves editing.

Also accepts the props of `React.ComponentProps<typeof Button>`.

### SelectionListItem

A row that becomes a selectable checkbox while editing.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | Required | The unique value that identifies the row in the selection. |

Also accepts the props of `React.ComponentProps<"div">`.

### SelectionListBar

The floating toolbar shown while editing.

Also accepts the props of `React.ComponentProps<"div">`.

### SelectionListCount

A live label with the number of selected rows.

Also accepts the props of `React.ComponentProps<"span">`.

## Accessibility

- `SelectionListTrigger` sets `aria-pressed` while editing.
- In editing mode, each row has `role="checkbox"` with `aria-checked`, is focusable, and toggles with `Enter` or `Space`. Its accessible name comes from the row's text unless you set `aria-label`.
- `SelectionListBar` has `role="toolbar"` and is `inert` while not editing; give it an `aria-label`.
- `SelectionListCount` is a polite live region, so selection changes are announced.

## Notes

- The root sets `data-editing` while editing, available to children as the `group/selection-list` Tailwind group. Rows expose `data-value` and `data-checked`.
- Parts use `data-slot` values `selection-list`, `selection-list-trigger`, `selection-list-item`, `selection-list-indicator`, `selection-list-item-content`, `selection-list-bar`, and `selection-list-count`.
- The bar is fixed to the bottom of the viewport, so leave room below the list for it.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-selection-list--default.light.webp)

```tsx
import { CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: "books", title: "Book list", preview: "Three novels for the summer" },
  { id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]

type Note = (typeof initialNotes)[number]

function NoteRows({
  notes,
  onOpen,
}: {
  notes: Note[]
  onOpen: (note: Note) => void
}) {
  const { editing } = useSelectionList()
  return (
    <View className="overflow-hidden rounded-2xl border border-border">
      {notes.map((note, index) => (
        <SelectionListItem
          key={note.id}
          value={note.id}
          className={index > 0 ? "border-t border-border" : undefined}
        >
          <Pressable
            onPress={() => onOpen(note)}
            className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:opacity-80"
          >
            <View className="min-w-0 flex-1">
              <Text numberOfLines={1} className="font-semibold">
                {note.title}
              </Text>
              <Text numberOfLines={1} className="text-sm text-muted-foreground">
                {note.preview}
              </Text>
            </View>
            <Icon
              as={CaretRightIcon}
              className={
                editing
                  ? "size-4 shrink-0 text-muted-foreground opacity-0"
                  : "size-4 shrink-0 text-muted-foreground"
              }
            />
          </Pressable>
        </SelectionListItem>
      ))}
    </View>
  )
}

function SelectAllButton() {
  const { selected, selectAll, clear } = useSelectionList()
  const all = selected.length > 0
  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4"
      onPress={all ? clear : selectAll}
    >
      <Text>{all ? "Deselect All" : "Select All"}</Text>
    </Button>
  )
}

function DeleteButton({ onDelete }: { onDelete: (ids: string[]) => void }) {
  const { selected, setEditing } = useSelectionList()
  const disabled = selected.length === 0
  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4"
      disabled={disabled}
      onPress={() => {
        onDelete(selected)
        setEditing(false)
      }}
    >
      <Text
        className={
          disabled ? "text-destructive opacity-50" : "text-destructive"
        }
      >
        Delete
      </Text>
    </Button>
  )
}

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Tap Select to choose notes.")
  return {
    notes,
    log,
    setLog,
    remove: (ids: string[]) => {
      setNotes((current) => current.filter((note) => !ids.includes(note.id)))
      setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
    },
    reset: () => {
      setNotes(initialNotes)
      setLog("Tap Select to choose notes.")
    },
  }
}

function Footer({ log, onReset }: { log: string; onReset: () => void }) {
  return (
    <View className="flex-row items-center justify-between gap-3">
      <Text
        role="status"
        className="min-w-0 flex-1 text-sm text-muted-foreground"
      >
        {log}
      </Text>
      <Button size="sm" variant="secondary" onPress={onReset}>
        <Text>Reset</Text>
      </Button>
    </View>
  )
}

function NotesExample({
  defaultEditing,
  defaultValue,
}: {
  defaultEditing?: boolean
  defaultValue?: string[]
}) {
  const { notes, log, setLog, remove, reset } = useNotes()
  return (
    <SelectionList
      defaultEditing={defaultEditing}
      defaultValue={defaultValue}
      className="w-full gap-3"
    >
      <View className="flex-row items-center justify-between">
        <Text variant="h1">Notes</Text>
        <SelectionListTrigger />
      </View>
      <NoteRows
        notes={notes}
        onOpen={(note) => setLog(`Opened ${note.title}`)}
      />
      <Footer log={log} onReset={reset} />
      <SelectionListBar accessibilityLabel="Selection actions">
        <SelectAllButton />
        <SelectionListCount />
        <DeleteButton onDelete={remove} />
      </SelectionListBar>
    </SelectionList>
  )
}

export function SelectionListExample() {
  return (
    <NotesExample />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct SelectionListNote: Identifiable {
  let id: String
  let title: String
  let preview: String
}

let selectionListInitialNotes = [
  SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
  SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
  SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]

let selectionListIdleLog = "Tap Select to choose notes."

struct SelectionListRows: View {
  let notes: [SelectionListNote]
  let editing: Bool
  let onOpen: (SelectionListNote) -> Void

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(spacing: 0) {
      ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
        SelectionListItem(value: note.id) {
          Button {
            onOpen(note)
          } label: {
            HStack(spacing: 12) {
              VStack(alignment: .leading, spacing: 0) {
                Text(verbatim: note.title)
                  .textStyle(.base, weight: .semibold)
                  .foregroundStyle(EsuissColor.foreground)
                  .lineLimit(1)
                Text(verbatim: note.preview)
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .lineLimit(1)
              }
              .frame(maxWidth: .infinity, alignment: .leading)
              Ph.caretRight.regular
                .frame(width: 16, height: 16)
                .foregroundStyle(EsuissColor.mutedForeground)
                .opacity(editing ? 0 : 1)
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .contentShape(Rectangle())
          }
          .buttonStyle(SelectionListRowStyle())
        }
        .overlay(alignment: .top) {
          if index > 0 {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
  }
}

struct SelectionListRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

struct SelectionListFooter: View {
  let log: String
  let onReset: () -> Void

  var body: some View {
    HStack(spacing: 12) {
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
      Button("Reset", action: onReset)
        .buttonStyle(.variant(.secondary, size: .sm))
    }
  }
}

struct SelectionListStage<Content: View, Bar: View>: View {
  @ViewBuilder let content: () -> Content
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      content()
    }
    .frame(maxHeight: .infinity, alignment: .top)
    .overlay(alignment: .bottom) {
      bar()
        .padding(.horizontal, -16)
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SelectionListNotes: View {
  @State private var editing: Bool
  @State private var selection: Set<String>
  @State private var notes = selectionListInitialNotes
  @State private var log = selectionListIdleLog

  init(editing: Bool = false, selection: Set<String> = []) {
    _editing = State(initialValue: editing)
    _selection = State(initialValue: selection)
  }

  var body: some View {
    SelectionList(isEditing: $editing, selection: $selection) {
      SelectionListStage {
        HStack {
          Text("Notes")
            .textVariant(.h1)
          Spacer()
          SelectionListTrigger()
        }
        SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
        SelectionListFooter(log: log) {
          notes = selectionListInitialNotes
          log = selectionListIdleLog
        }
      } bar: {
        SelectionListBar { list in
          Button(list.count > 0 ? "Deselect All" : "Select All") {
            if list.count > 0 { list.clear() } else { list.selectAll() }
          }
          .buttonStyle(.variant(.plain, size: .lg))
          SelectionListCount()
          Button {
            let ids = selection
            notes.removeAll { ids.contains($0.id) }
            log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
            selection = []
            list.setEditing(false)
          } label: {
            Text("Delete")
              .foregroundStyle(EsuissColor.destructive)
          }
          .buttonStyle(.variant(.plain, size: .lg))
          .disabled(list.count == 0)
        }
        .accessibilityLabel(Text("Selection actions"))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

class SelectionListNote(val id: String, val title: String, val preview: String)

val selectionListInitialNotes = listOf(
    SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
    SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
    SelectionListNote("books", "Book list", "Three novels for the summer"),
    SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
    SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
    SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)

@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        notes.forEachIndexed { index, note ->
            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
            SelectionListItem(value = note.id, contentDescription = note.title) {
                SelectionListRowContent(note, editing) { onOpen(note) }
            }
        }
    }
}

@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            log,
            modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
        Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
    }
}

@Composable
fun SelectionListHeader(title: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(title, variant = TextVariant.H1)
        SelectionListTrigger()
    }
}

fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"

@Composable
fun SelectionListNotes(defaultEditing: Boolean = false, defaultValue: List<String> = emptyList()) {
    val colors = EsuissTheme.colors
    var notes by remember { mutableStateOf(selectionListInitialNotes) }
    var log by rememberSaveable { mutableStateOf(SelectionListHint) }
    var editing by rememberSaveable { mutableStateOf(defaultEditing) }
    var selected by rememberSaveable { mutableStateOf(defaultValue) }
    SelectionList(
        editing = editing,
        onEditingChange = { editing = it },
        value = selected,
        onValueChange = { selected = it },
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            SelectionListHeader("Notes")
            SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
            SelectionListFooter(log) {
                notes = selectionListInitialNotes
                log = SelectionListHint
            }
        }
        SelectionListBar {
            val any = this@SelectionList.selected.isNotEmpty()
            Button(
                if (any) "Deselect All" else "Select All",
                onClick = { if (any) clear() else selectAll() },
                modifier = Modifier.padding(horizontal = 4.dp),
                variant = ButtonVariant.Plain,
                size = ButtonSize.Lg,
            )
            SelectionListCount()
            Button(
                onClick = {
                    val ids = this@SelectionList.selected
                    notes = notes.filterNot { it.id in ids }
                    log = selectionListDeleted(ids.size)
                    setEditing(false)
                },
                modifier = Modifier.padding(horizontal = 4.dp),
                variant = ButtonVariant.Plain,
                size = ButtonSize.Lg,
                enabled = any,
            ) {
                Text("Delete", style = Typography.base.copy(color = colors.destructive))
            }
        }
    }
}
```

`SelectionList` switches a list between normal and editing modes. In editing mode each `SelectionListItem` slides in a check circle at its start, the whole row toggles on press, and selected rows are highlighted.

`SelectionListBar` floats above the bottom safe area while editing, sliding up into view, for actions such as delete or move. Leaving editing mode clears the selection. On Android the hardware back button also leaves it. Use it for mail, notes or photo lists that need bulk actions.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/selection-list
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add selection-list
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add selection-list --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add selection-list --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/selection-list.tsx`:

```tsx title="uniwind/components/interactions/selection-list.tsx"
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type SelectionListContextProps = {
  editing: boolean
  setEditing: (editing: boolean) => void
  selected: string[]
  setSelected: (selected: string[]) => void
  toggle: (value: string) => void
  selectAll: () => void
  clear: () => void
}

const SelectionListContext =
  React.createContext<SelectionListContextProps | null>(null)

const SelectionListItemsContext = React.createContext<
  ((value: string) => () => void) | null
>(null)

function useSelectionList() {
  const context = React.useContext(SelectionListContext)
  if (!context) {
    throw new Error("useSelectionList must be used within a <SelectionList />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const EMPTY: string[] = []

const BAR_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const BAR_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

function wrapText(children: React.ReactNode) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className="font-semibold">{children}</Text>
  }
  return children
}

function SelectionList({
  editing: editingProp,
  defaultEditing = false,
  onEditingChange,
  value,
  defaultValue = EMPTY,
  onValueChange,
  className,
  ...props
}: ViewProps & {
  editing?: boolean
  defaultEditing?: boolean
  onEditingChange?: (editing: boolean) => void
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}) {
  const [uncontrolledEditing, setUncontrolledEditing] =
    React.useState(defaultEditing)
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const editing = editingProp ?? uncontrolledEditing
  const selected = value ?? uncontrolledValue
  const valuesRef = React.useRef<string[]>([])

  const setSelected = React.useCallback(
    (next: string[]) => {
      if (next.length === 0 && selected.length === 0) return
      if (value === undefined) setUncontrolledValue(next)
      onValueChange?.(next)
    },
    [value, selected, onValueChange]
  )

  const setEditing = React.useCallback(
    (next: boolean) => {
      if (editingProp === undefined) setUncontrolledEditing(next)
      onEditingChange?.(next)
      if (!next) setSelected([])
    },
    [editingProp, onEditingChange, setSelected]
  )

  const register = React.useCallback((item: string) => {
    valuesRef.current = [...valuesRef.current, item]
    return () => {
      valuesRef.current = valuesRef.current.filter((entry) => entry !== item)
    }
  }, [])

  useBackHandler(editing, () => setEditing(false))

  const contextValue = React.useMemo<SelectionListContextProps>(
    () => ({
      editing,
      setEditing,
      selected,
      setSelected,
      toggle: (item) =>
        setSelected(
          selected.includes(item)
            ? selected.filter((entry) => entry !== item)
            : [...selected, item]
        ),
      selectAll: () => setSelected([...valuesRef.current]),
      clear: () => setSelected([]),
    }),
    [editing, setEditing, selected, setSelected]
  )

  return (
    <SelectionListContext.Provider value={contextValue}>
      <SelectionListItemsContext.Provider value={register}>
        <View className={className} {...props} />
      </SelectionListItemsContext.Provider>
    </SelectionListContext.Provider>
  )
}

function SelectionListTrigger({
  children,
  onPress,
  accessibilityState,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { editing, setEditing } = useSelectionList()
  return (
    <Button
      variant="plain"
      accessibilityState={{ ...accessibilityState, selected: editing }}
      onPress={(event) => {
        onPress?.(event)
        setEditing(!editing)
      }}
      {...props}
    >
      {typeof children === "function"
        ? children
        : wrapText(children ?? (editing ? "Done" : "Select"))}
    </Button>
  )
}

const EDIT_TIMING = {
  duration: 400,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const RING_TIMING = {
  duration: 300,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const CHECK_TIMING = { duration: 200, reduceMotion: ReduceMotion.System }

function SelectionListItem({
  value,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  value: string
  children?: React.ReactNode
}) {
  const { editing, selected, toggle } = useSelectionList()
  const register = React.useContext(SelectionListItemsContext)
  const checked = selected.includes(value)

  React.useEffect(() => register?.(value), [register, value])

  const slotStyle = useAnimatedStyle(
    () => ({
      width: withTiming(editing ? 40 : 0, EDIT_TIMING),
      opacity: withTiming(editing ? 1 : 0, EDIT_TIMING),
    }),
    [editing]
  )

  const ringStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(editing ? 1 : 0.5, RING_TIMING) }],
    }),
    [editing]
  )

  const checkStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(checked ? 1 : 0, CHECK_TIMING) }],
    }),
    [checked]
  )

  return (
    <Pressable
      accessible={editing}
      disabled={!editing}
      role={editing ? "checkbox" : undefined}
      accessibilityState={{
        ...accessibilityState,
        ...(editing ? { checked } : undefined),
      }}
      onPress={(event) => {
        onPress?.(event)
        toggle(value)
      }}
      className={cn("flex-row items-center", checked && "bg-accent", className)}
      {...props}
    >
      <AnimatedView
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        className="shrink-0 flex-row items-center overflow-hidden"
        style={slotStyle}
      >
        <AnimatedView
          className={cn(
            "ms-4 size-5.5 shrink-0 items-center justify-center rounded-full border-[1.5px] border-muted-foreground",
            checked && "border-primary bg-primary"
          )}
          style={ringStyle}
        >
          <AnimatedView style={checkStyle}>
            <Icon
              as={CheckIcon}
              weight="bold"
              className="size-3.5 text-primary-foreground"
            />
          </AnimatedView>
        </AnimatedView>
      </AnimatedView>
      <View
        className="min-w-0 flex-1 flex-row items-center"
        style={{ pointerEvents: editing ? "none" : "auto" }}
      >
        {children}
      </View>
    </Pressable>
  )
}

function SelectionListBar({
  className,
  portalHost,
  ...props
}: ViewProps & { portalHost?: string }) {
  const context = useSelectionList()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  return (
    <Portal name={`selection-list-bar-${id}`} hostName={portalHost}>
      <SelectionListContext.Provider value={context}>
        {context.editing ? (
          <AnimatedView
            entering={BAR_ENTERING}
            exiting={BAR_EXITING}
            className="absolute z-40 items-center"
            style={{
              left: insets.left + 16,
              right: insets.right + 16,
              bottom: Math.max(16, insets.bottom),
              pointerEvents: "box-none",
            }}
          >
            <View
              role="toolbar"
              className={cn(
                "h-13 w-full max-w-md flex-row items-center justify-between gap-2 rounded-full border border-border bg-card px-1.5 shadow-xl dark:bg-muted",
                className
              )}
              {...props}
            />
          </AnimatedView>
        ) : null}
      </SelectionListContext.Provider>
    </Portal>
  )
}

function SelectionListCount({
  className,
  children,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { selected } = useSelectionList()
  return (
    <Text
      numberOfLines={1}
      accessibilityLiveRegion="polite"
      className={cn(
        "min-w-0 flex-1 text-center text-sm font-medium tabular-nums",
        className
      )}
      {...props}
    >
      {children ??
        (selected.length === 0
          ? "Select items"
          : `${selected.length} selected`)}
    </Text>
  )
}

export {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
  useSelectionList,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/selection-list.tsx`:

```tsx title="nativewind/components/interactions/selection-list.tsx"
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View, type ViewProps } from "react-native"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type SelectionListContextProps = {
  editing: boolean
  setEditing: (editing: boolean) => void
  selected: string[]
  setSelected: (selected: string[]) => void
  toggle: (value: string) => void
  selectAll: () => void
  clear: () => void
}

const SelectionListContext =
  React.createContext<SelectionListContextProps | null>(null)

const SelectionListItemsContext = React.createContext<
  ((value: string) => () => void) | null
>(null)

function useSelectionList() {
  const context = React.useContext(SelectionListContext)
  if (!context) {
    throw new Error("useSelectionList must be used within a <SelectionList />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const EMPTY: string[] = []

const BAR_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const BAR_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

function wrapText(children: React.ReactNode) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className="font-semibold">{children}</Text>
  }
  return children
}

function SelectionList({
  editing: editingProp,
  defaultEditing = false,
  onEditingChange,
  value,
  defaultValue = EMPTY,
  onValueChange,
  className,
  ...props
}: ViewProps & {
  editing?: boolean
  defaultEditing?: boolean
  onEditingChange?: (editing: boolean) => void
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}) {
  const [uncontrolledEditing, setUncontrolledEditing] =
    React.useState(defaultEditing)
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const editing = editingProp ?? uncontrolledEditing
  const selected = value ?? uncontrolledValue
  const valuesRef = React.useRef<string[]>([])

  const setSelected = React.useCallback(
    (next: string[]) => {
      if (next.length === 0 && selected.length === 0) return
      if (value === undefined) setUncontrolledValue(next)
      onValueChange?.(next)
    },
    [value, selected, onValueChange]
  )

  const setEditing = React.useCallback(
    (next: boolean) => {
      if (editingProp === undefined) setUncontrolledEditing(next)
      onEditingChange?.(next)
      if (!next) setSelected([])
    },
    [editingProp, onEditingChange, setSelected]
  )

  const register = React.useCallback((item: string) => {
    valuesRef.current = [...valuesRef.current, item]
    return () => {
      valuesRef.current = valuesRef.current.filter((entry) => entry !== item)
    }
  }, [])

  useBackHandler(editing, () => setEditing(false))

  const contextValue = React.useMemo<SelectionListContextProps>(
    () => ({
      editing,
      setEditing,
      selected,
      setSelected,
      toggle: (item) =>
        setSelected(
          selected.includes(item)
            ? selected.filter((entry) => entry !== item)
            : [...selected, item]
        ),
      selectAll: () => setSelected([...valuesRef.current]),
      clear: () => setSelected([]),
    }),
    [editing, setEditing, selected, setSelected]
  )

  return (
    <SelectionListContext.Provider value={contextValue}>
      <SelectionListItemsContext.Provider value={register}>
        <View className={className} {...props} />
      </SelectionListItemsContext.Provider>
    </SelectionListContext.Provider>
  )
}

function SelectionListTrigger({
  children,
  onPress,
  accessibilityState,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { editing, setEditing } = useSelectionList()
  return (
    <Button
      variant="plain"
      accessibilityState={{ ...accessibilityState, selected: editing }}
      onPress={(event) => {
        onPress?.(event)
        setEditing(!editing)
      }}
      {...props}
    >
      {typeof children === "function"
        ? children
        : wrapText(children ?? (editing ? "Done" : "Select"))}
    </Button>
  )
}

const EDIT_TIMING = {
  duration: 400,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const RING_TIMING = {
  duration: 300,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const CHECK_TIMING = { duration: 200, reduceMotion: ReduceMotion.System }

function SelectionListItem({
  value,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  value: string
  children?: React.ReactNode
}) {
  const { editing, selected, toggle } = useSelectionList()
  const register = React.useContext(SelectionListItemsContext)
  const checked = selected.includes(value)

  React.useEffect(() => register?.(value), [register, value])

  const slotStyle = useAnimatedStyle(
    () => ({
      width: withTiming(editing ? 40 : 0, EDIT_TIMING),
      opacity: withTiming(editing ? 1 : 0, EDIT_TIMING),
    }),
    [editing]
  )

  const ringStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(editing ? 1 : 0.5, RING_TIMING) }],
    }),
    [editing]
  )

  const checkStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(checked ? 1 : 0, CHECK_TIMING) }],
    }),
    [checked]
  )

  return (
    <Pressable
      accessible={editing}
      disabled={!editing}
      role={editing ? "checkbox" : undefined}
      accessibilityState={{
        ...accessibilityState,
        ...(editing ? { checked } : undefined),
      }}
      onPress={(event) => {
        onPress?.(event)
        toggle(value)
      }}
      className={cn("flex-row items-center", checked && "bg-accent", className)}
      {...props}
    >
      <AnimatedView
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        className="shrink-0 flex-row items-center overflow-hidden"
        style={slotStyle}
      >
        <AnimatedView
          className={cn(
            "ms-4 size-[1.375rem] shrink-0 items-center justify-center rounded-full border-[1.5px] border-muted-foreground",
            checked && "border-primary bg-primary"
          )}
          style={ringStyle}
        >
          <AnimatedView style={checkStyle}>
            <Icon
              as={CheckIcon}
              weight="bold"
              className="size-3.5 text-primary-foreground"
            />
          </AnimatedView>
        </AnimatedView>
      </AnimatedView>
      <View
        className="min-w-0 flex-1 flex-row items-center"
        style={{ pointerEvents: editing ? "none" : "auto" }}
      >
        {children}
      </View>
    </Pressable>
  )
}

function SelectionListBar({
  className,
  portalHost,
  ...props
}: ViewProps & { portalHost?: string }) {
  const context = useSelectionList()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  return (
    <Portal name={`selection-list-bar-${id}`} hostName={portalHost}>
      <SelectionListContext.Provider value={context}>
        {context.editing ? (
          <AnimatedView
            entering={BAR_ENTERING}
            exiting={BAR_EXITING}
            className="absolute z-40 items-center"
            style={{
              left: insets.left + 16,
              right: insets.right + 16,
              bottom: Math.max(16, insets.bottom),
              pointerEvents: "box-none",
            }}
          >
            <View
              role="toolbar"
              className={cn(
                "h-[3.25rem] w-full max-w-md flex-row items-center justify-between gap-2 rounded-full border border-border bg-card px-1.5 shadow-xl dark:bg-muted",
                className
              )}
              {...props}
            />
          </AnimatedView>
        ) : null}
      </SelectionListContext.Provider>
    </Portal>
  )
}

function SelectionListCount({
  className,
  children,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { selected } = useSelectionList()
  return (
    <Text
      numberOfLines={1}
      accessibilityLiveRegion="polite"
      className={cn(
        "min-w-0 flex-1 text-center text-sm font-medium tabular-nums",
        className
      )}
      {...props}
    >
      {children ??
        (selected.length === 0
          ? "Select items"
          : `${selected.length} selected`)}
    </Text>
  )
}

export {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
  useSelectionList,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/interactions/selection-list.tsx`:

```tsx title="stylesheet/components/interactions/selection-list.tsx"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View, type ViewProps } from "react-native"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

type SelectionListContextProps = {
  editing: boolean
  setEditing: (editing: boolean) => void
  selected: string[]
  setSelected: (selected: string[]) => void
  toggle: (value: string) => void
  selectAll: () => void
  clear: () => void
}

const SelectionListContext =
  React.createContext<SelectionListContextProps | null>(null)

const SelectionListItemsContext = React.createContext<
  ((value: string) => () => void) | null
>(null)

function useSelectionList() {
  const context = React.useContext(SelectionListContext)
  if (!context) {
    throw new Error("useSelectionList must be used within a <SelectionList />")
  }
  return context
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const EMPTY: string[] = []

const BAR_ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const BAR_EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const styles = StyleSheet.create({
  item: { flexDirection: "row", alignItems: "center" },
  slot: {
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
    overflow: "hidden",
  },
  ring: {
    marginStart: 16,
    width: 22,
    height: 22,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    borderWidth: 1.5,
  },
  content: {
    minWidth: 0,
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
  },
  barFrame: { position: "absolute", zIndex: 40, alignItems: "center" },
  bar: {
    height: 52,
    width: "100%",
    maxWidth: 448,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 8,
    borderRadius: radius.full,
    borderWidth: 1,
    paddingHorizontal: 6,
    ...shadow.xl,
  },
  count: {
    minWidth: 0,
    flex: 1,
    textAlign: "center",
    ...fontSize.sm,
    fontWeight: "500",
    fontVariant: ["tabular-nums"],
  },
  trigger: { fontWeight: "600" },
})

function wrapText(children: React.ReactNode) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text style={styles.trigger}>{children}</Text>
  }
  return children
}

function SelectionList({
  editing: editingProp,
  defaultEditing = false,
  onEditingChange,
  value,
  defaultValue = EMPTY,
  onValueChange,
  ...props
}: ViewProps & {
  editing?: boolean
  defaultEditing?: boolean
  onEditingChange?: (editing: boolean) => void
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
}) {
  const [uncontrolledEditing, setUncontrolledEditing] =
    React.useState(defaultEditing)
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const editing = editingProp ?? uncontrolledEditing
  const selected = value ?? uncontrolledValue
  const valuesRef = React.useRef<string[]>([])

  const setSelected = React.useCallback(
    (next: string[]) => {
      if (next.length === 0 && selected.length === 0) return
      if (value === undefined) setUncontrolledValue(next)
      onValueChange?.(next)
    },
    [value, selected, onValueChange]
  )

  const setEditing = React.useCallback(
    (next: boolean) => {
      if (editingProp === undefined) setUncontrolledEditing(next)
      onEditingChange?.(next)
      if (!next) setSelected([])
    },
    [editingProp, onEditingChange, setSelected]
  )

  const register = React.useCallback((item: string) => {
    valuesRef.current = [...valuesRef.current, item]
    return () => {
      valuesRef.current = valuesRef.current.filter((entry) => entry !== item)
    }
  }, [])

  useBackHandler(editing, () => setEditing(false))

  const contextValue = React.useMemo<SelectionListContextProps>(
    () => ({
      editing,
      setEditing,
      selected,
      setSelected,
      toggle: (item) =>
        setSelected(
          selected.includes(item)
            ? selected.filter((entry) => entry !== item)
            : [...selected, item]
        ),
      selectAll: () => setSelected([...valuesRef.current]),
      clear: () => setSelected([]),
    }),
    [editing, setEditing, selected, setSelected]
  )

  return (
    <SelectionListContext.Provider value={contextValue}>
      <SelectionListItemsContext.Provider value={register}>
        <View {...props} />
      </SelectionListItemsContext.Provider>
    </SelectionListContext.Provider>
  )
}

function SelectionListTrigger({
  children,
  onPress,
  accessibilityState,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { editing, setEditing } = useSelectionList()
  return (
    <Button
      variant="plain"
      accessibilityState={{ ...accessibilityState, selected: editing }}
      onPress={(event) => {
        onPress?.(event)
        setEditing(!editing)
      }}
      {...props}
    >
      {typeof children === "function"
        ? children
        : wrapText(children ?? (editing ? "Done" : "Select"))}
    </Button>
  )
}

const EDIT_TIMING = {
  duration: 400,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const RING_TIMING = {
  duration: 300,
  easing: EASING,
  reduceMotion: ReduceMotion.System,
}

const CHECK_TIMING = { duration: 200, reduceMotion: ReduceMotion.System }

function SelectionListItem({
  value,
  style,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  value: string
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  const { editing, selected, toggle } = useSelectionList()
  const register = React.useContext(SelectionListItemsContext)
  const checked = selected.includes(value)

  React.useEffect(() => register?.(value), [register, value])

  const slotStyle = useAnimatedStyle(
    () => ({
      width: withTiming(editing ? 40 : 0, EDIT_TIMING),
      opacity: withTiming(editing ? 1 : 0, EDIT_TIMING),
    }),
    [editing]
  )

  const ringStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(editing ? 1 : 0.5, RING_TIMING) }],
    }),
    [editing]
  )

  const checkStyle = useAnimatedStyle(
    () => ({
      transform: [{ scale: withTiming(checked ? 1 : 0, CHECK_TIMING) }],
    }),
    [checked]
  )

  return (
    <Pressable
      accessible={editing}
      disabled={!editing}
      role={editing ? "checkbox" : undefined}
      accessibilityState={{
        ...accessibilityState,
        ...(editing ? { checked } : undefined),
      }}
      onPress={(event) => {
        onPress?.(event)
        toggle(value)
      }}
      style={(state) => [
        styles.item,
        checked && { backgroundColor: colors.accent },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    >
      <AnimatedView
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={[styles.slot, slotStyle]}
      >
        <AnimatedView
          style={[
            styles.ring,
            checked
              ? { borderColor: colors.primary, backgroundColor: colors.primary }
              : { borderColor: colors.mutedForeground },
            ringStyle,
          ]}
        >
          <AnimatedView style={checkStyle}>
            <Icon
              as={CheckIcon}
              weight="bold"
              size={14}
              color={colors.primaryForeground}
            />
          </AnimatedView>
        </AnimatedView>
      </AnimatedView>
      <View
        style={[styles.content, { pointerEvents: editing ? "none" : "auto" }]}
      >
        {children}
      </View>
    </Pressable>
  )
}

function SelectionListBar({
  style,
  portalHost,
  ...props
}: ViewProps & { portalHost?: string }) {
  const { colors, isDark } = useTheme()
  const context = useSelectionList()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  return (
    <Portal name={`selection-list-bar-${id}`} hostName={portalHost}>
      <SelectionListContext.Provider value={context}>
        {context.editing ? (
          <AnimatedView
            entering={BAR_ENTERING}
            exiting={BAR_EXITING}
            style={[
              styles.barFrame,
              {
                left: insets.left + 16,
                right: insets.right + 16,
                bottom: Math.max(16, insets.bottom),
                pointerEvents: "box-none",
              },
            ]}
          >
            <View
              role="toolbar"
              style={[
                styles.bar,
                {
                  borderColor: colors.border,
                  backgroundColor: isDark ? colors.muted : colors.card,
                },
                style,
              ]}
              {...props}
            />
          </AnimatedView>
        ) : null}
      </SelectionListContext.Provider>
    </Portal>
  )
}

function SelectionListCount({
  style,
  children,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { selected } = useSelectionList()
  return (
    <Text
      numberOfLines={1}
      accessibilityLiveRegion="polite"
      style={[styles.count, style]}
      {...props}
    >
      {children ??
        (selected.length === 0
          ? "Select items"
          : `${selected.length} selected`)}
    </Text>
  )
}

export {
  SelectionList,
  SelectionListBar,
  SelectionListCount,
  SelectionListItem,
  SelectionListTrigger,
  useSelectionList,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Interactions/SelectionList.swift"
import PhosphorSwift
import SwiftUI

struct SelectionModel<Value: Hashable> {
  var editing = false
  var selected: Set<Value> = []
  var order: [Value] = []

  mutating func toggle(_ value: Value) {
    if selected.contains(value) {
      selected.remove(value)
    } else {
      selected.insert(value)
    }
  }

  mutating func selectAll() {
    selected = Set(order)
  }

  mutating func clear() {
    selected = []
  }

  mutating func setEditing(_ value: Bool) {
    editing = value
    if !value { selected = [] }
  }
}

struct SelectionListContext {
  var editing: Bool
  var count: Int
  var total: Int
  var isSelected: (AnyHashable) -> Bool
  var toggle: (AnyHashable) -> Void
  var setEditing: (Bool) -> Void
  var selectAll: () -> Void
  var clear: () -> Void
}

extension EnvironmentValues {
  @Entry var selectionList: SelectionListContext?
}

struct SelectionListValuesKey: PreferenceKey {
  static var defaultValue: [AnyHashable] { [] }

  static func reduce(value: inout [AnyHashable], nextValue: () -> [AnyHashable]) {
    value.append(contentsOf: nextValue())
  }
}

let selectionListAnimation = Animation.timingCurve(0.32, 0.72, 0, 1, duration: 0.4)

public struct SelectionListProxy {
  let context: SelectionListContext

  public var isEditing: Bool { context.editing }
  public var count: Int { context.count }
  public var total: Int { context.total }

  public func setEditing(_ value: Bool) {
    context.setEditing(value)
  }

  public func selectAll() {
    context.selectAll()
  }

  public func clear() {
    context.clear()
  }
}

public struct SelectionList<Value: Hashable, Content: View>: View {
  let editingBinding: Binding<Bool>?
  let selectionBinding: Binding<Set<Value>>?
  let content: Content

  @State private var localEditing: Bool
  @State private var localSelection: Set<Value>
  @State private var order: [Value] = []
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    isEditing: Binding<Bool>? = nil,
    selection: Binding<Set<Value>>? = nil,
    defaultEditing: Bool = false,
    defaultSelection: Set<Value> = [],
    @ViewBuilder content: () -> Content
  ) {
    editingBinding = isEditing
    selectionBinding = selection
    _localEditing = State(initialValue: defaultEditing)
    _localSelection = State(initialValue: defaultSelection)
    self.content = content()
  }

  private var model: SelectionModel<Value> {
    SelectionModel(
      editing: editingBinding?.wrappedValue ?? localEditing,
      selected: selectionBinding?.wrappedValue ?? localSelection,
      order: order
    )
  }

  private func update(_ change: (inout SelectionModel<Value>) -> Void) {
    var next = model
    change(&next)
    if next.selected != model.selected {
      if let selectionBinding {
        selectionBinding.wrappedValue = next.selected
      } else {
        localSelection = next.selected
      }
    }
    if next.editing != model.editing {
      let apply = {
        if let editingBinding {
          editingBinding.wrappedValue = next.editing
        } else {
          localEditing = next.editing
        }
      }
      if reduceMotion {
        apply()
      } else {
        withAnimation(selectionListAnimation, apply)
      }
    }
  }

  public var body: some View {
    let current = model
    let context = SelectionListContext(
      editing: current.editing,
      count: current.selected.count,
      total: order.count,
      isSelected: { value in (value.base as? Value).map(current.selected.contains) ?? false },
      toggle: { value in
        guard let value = value.base as? Value else { return }
        update { $0.toggle(value) }
      },
      setEditing: { value in update { $0.setEditing(value) } },
      selectAll: { update { $0.selectAll() } },
      clear: { update { $0.clear() } }
    )
    content
      .environment(\.selectionList, context)
      .onPreferenceChange(SelectionListValuesKey.self) { values in
        order = values.compactMap { $0.base as? Value }
      }
      .accessibilityAction(.escape) {
        if current.editing { context.setEditing(false) }
      }
  }
}

public struct SelectionListTrigger<Label: View>: View {
  let label: Label?

  @Environment(\.selectionList) private var list

  public init(@ViewBuilder label: () -> Label) {
    self.label = label()
  }

  public var body: some View {
    let editing = list?.editing ?? false
    Button {
      list?.setEditing(!editing)
    } label: {
      if let label {
        label
      } else {
        Text(editing ? "Done" : "Select").fontWeight(.semibold)
      }
    }
    .buttonStyle(.variant(.plain))
    .fontWeight(.semibold)
    .accessibilityAddTraits(editing ? .isSelected : [])
  }
}

extension SelectionListTrigger where Label == Text {
  public init() {
    label = nil
  }

  public init(_ title: LocalizedStringKey) {
    label = Text(title).fontWeight(.semibold)
  }
}

public struct SelectionListItem<Value: Hashable, Content: View>: View {
  let value: Value
  let content: Content

  @Environment(\.selectionList) private var list

  public init(value: Value, @ViewBuilder content: () -> Content) {
    self.value = value
    self.content = content()
  }

  public var body: some View {
    let editing = list?.editing ?? false
    let checked = list?.isSelected(AnyHashable(value)) ?? false
    HStack(spacing: 0) {
      SelectionListIndicator(editing: editing, checked: checked)
      content
        .frame(maxWidth: .infinity, alignment: .leading)
        .allowsHitTesting(!editing)
    }
    .background(checked ? EsuissColor.accent : .clear)
    .contentShape(Rectangle())
    .onTapGesture {
      guard editing else { return }
      list?.toggle(AnyHashable(value))
    }
    .animation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.2), value: checked)
    .preference(key: SelectionListValuesKey.self, value: [AnyHashable(value)])
    .modifier(SelectionItemAccessibility(editing: editing, checked: checked) {
      list?.toggle(AnyHashable(value))
    })
  }
}

private struct SelectionItemAccessibility: ViewModifier {
  let editing: Bool
  let checked: Bool
  let toggle: () -> Void

  func body(content: Content) -> some View {
    if editing {
      content
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(checked ? [.isButton, .isSelected] : .isButton)
        .accessibilityAction { toggle() }
    } else {
      content
    }
  }
}

struct SelectionListIndicator: View {
  let editing: Bool
  let checked: Bool

  var body: some View {
    ZStack {
      Circle()
        .strokeBorder(checked ? EsuissColor.primary : EsuissColor.mutedForeground, lineWidth: 1.5)
      if checked {
        Circle().fill(EsuissColor.primary)
        Ph.check.bold
          .frame(width: 14, height: 14)
          .foregroundStyle(EsuissColor.primaryForeground)
          .transition(.scale)
      }
    }
    .frame(width: 22, height: 22)
    .scaleEffect(editing ? 1 : 0.5)
    .padding(.leading, 16)
    .frame(width: editing ? 40 : 0, alignment: .leading)
    .opacity(editing ? 1 : 0)
    .clipped()
    .accessibilityHidden(true)
  }
}

public struct SelectionListBar<Content: View>: View {
  let content: (SelectionListProxy) -> Content

  @Environment(\.selectionList) private var list

  public init(@ViewBuilder content: @escaping (SelectionListProxy) -> Content) {
    self.content = content
  }

  public init(@ViewBuilder content: @escaping () -> Content) {
    self.content = { _ in content() }
  }

  public var body: some View {
    let editing = list?.editing ?? false
    FloatingBar(visible: editing) {
      HStack(spacing: 8) {
        if let list {
          content(SelectionListProxy(context: list))
        }
      }
      .padding(.horizontal, 6)
    }
    .accessibilityElement(children: .contain)
  }
}

public struct SelectionListCount: View {
  let text: Text?

  @Environment(\.selectionList) private var list

  public init() {
    text = nil
  }

  public init(_ title: LocalizedStringKey) {
    text = Text(title)
  }

  public var body: some View {
    let count = list?.count ?? 0
    Group {
      if let text {
        text
      } else if count == 0 {
        Text("Select items")
      } else {
        Text("\(count) selected")
      }
    }
    .scaledFont(.sm, weight: .medium)
    .monospacedDigit()
    .lineLimit(1)
    .multilineTextAlignment(.center)
    .foregroundStyle(EsuissColor.foreground)
    .frame(maxWidth: .infinity)
    .contentTransition(.numericText())
    .accessibilityAddTraits(.updatesFrequently)
  }
}

struct FloatingBar<Content: View>: View {
  let visible: Bool
  var maxWidth: CGFloat = 448
  @ViewBuilder let content: () -> Content

  @State private var bottomInset: CGFloat = 0
  @Environment(\.colorScheme) private var colorScheme
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    let dark = colorScheme == .dark
    content()
      .frame(maxWidth: .infinity, minHeight: 52, maxHeight: 52)
      .foregroundStyle(EsuissColor.foreground)
      .background {
        Capsule()
          .fill(.ultraThinMaterial)
          .overlay {
            Capsule().fill(dark ? EsuissColor.muted.opacity(0.75) : EsuissColor.card.opacity(0.8))
          }
          .shadow(Shadow(color: .black.opacity(dark ? 0.3 : 0.12), radius: 14, y: 10))
      }
      .overlay {
        Capsule().strokeBorder(EsuissColor.border)
      }
      .frame(maxWidth: maxWidth)
      .padding(.horizontal, 16)
      .padding(.bottom, 16)
      .scaleEffect(visible || reduceMotion ? 1 : 0.95, anchor: .bottom)
      .offset(y: min(16, bottomInset) + (visible || reduceMotion ? 0 : 52 + 16 + bottomInset))
      .background {
        Color.clear
          .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.bottom } action: { bottomInset = $0 }
      }
      .opacity(visible ? 1 : 0)
      .allowsHitTesting(visible)
      .accessibilityHidden(!visible)
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/interactions/SelectionList.kt"
package com.esuiss.ui.interactions

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.Check
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.FormEasing
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Stable
interface SelectionListScope {
    val editing: Boolean
    val selected: List<String>
    fun setEditing(editing: Boolean)
    fun setSelected(selected: List<String>)
    fun toggle(value: String)
    fun selectAll()
    fun clear()
}

private class SelectionListContext(
    editing: () -> Boolean,
    selected: () -> List<String>,
    private val onEditingChange: () -> (Boolean) -> Unit,
    private val onValueChange: () -> (List<String>) -> Unit,
) : SelectionListScope {
    private val editingState = editing
    private val selectedState = selected
    val values = mutableStateListOf<String>()

    override val editing: Boolean
        get() = editingState()

    override val selected: List<String>
        get() = selectedState()

    override fun setEditing(editing: Boolean) {
        onEditingChange()(editing)
        if (!editing) onValueChange()(emptyList())
    }

    override fun setSelected(selected: List<String>) {
        onValueChange()(selected)
    }

    override fun toggle(value: String) {
        val current = selected
        setSelected(if (value in current) current - value else current + value)
    }

    override fun selectAll() {
        setSelected(values.toList())
    }

    override fun clear() {
        setSelected(emptyList())
    }
}

private val LocalSelectionList = staticCompositionLocalOf<SelectionListContext?> { null }

@Composable
private fun selectionList(): SelectionListContext =
    LocalSelectionList.current ?: error("SelectionList parts must be used within a SelectionList")

@Composable
fun SelectionList(
    editing: Boolean,
    onEditingChange: (Boolean) -> Unit,
    value: List<String>,
    onValueChange: (List<String>) -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable SelectionListScope.() -> Unit,
) {
    val currentEditing by rememberUpdatedState(editing)
    val currentValue by rememberUpdatedState(value)
    val currentOnEditingChange by rememberUpdatedState(onEditingChange)
    val currentOnValueChange by rememberUpdatedState(onValueChange)
    val context = remember {
        SelectionListContext(
            editing = { currentEditing },
            selected = { currentValue },
            onEditingChange = { currentOnEditingChange },
            onValueChange = { currentOnValueChange },
        )
    }
    CompositionLocalProvider(LocalSelectionList provides context) {
        Box(modifier = modifier) {
            context.content()
        }
    }
}

@Composable
fun SelectionListTrigger(
    modifier: Modifier = Modifier,
    text: String? = null,
) {
    val context = selectionList()
    val editing = context.editing
    Button(
        onClick = { context.setEditing(!editing) },
        modifier = modifier,
        variant = ButtonVariant.Plain,
    ) {
        BasicText(
            text ?: if (editing) "Done" else "Select",
            style = LocalContentStyle.current.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
        )
    }
}

@Composable
fun SelectionListItem(
    value: String,
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
    content: @Composable RowScope.() -> Unit,
) {
    val context = selectionList()
    DisposableEffect(context, value) {
        context.values.add(value)
        onDispose { context.values.remove(value) }
    }
    val colors = EsuissTheme.colors
    val editing = context.editing
    val checked = value in context.selected
    val reduced = formReducedMotion()
    val slow = if (reduced) 0 else 400
    val medium = if (reduced) 0 else 300
    val fast = if (reduced) 0 else 200
    val indicatorWidth by animateDpAsState(if (editing) 40.dp else 0.dp, tween(slow, easing = FormEasing), label = "width")
    val indicatorAlpha by animateFloatAsState(if (editing) 1f else 0f, tween(slow, easing = FormEasing), label = "alpha")
    val indicatorScale by animateFloatAsState(if (editing) 1f else 0.5f, tween(medium, easing = FormEasing), label = "scale")
    val checkScale by animateFloatAsState(if (checked) 1f else 0f, tween(fast), label = "check")
    val background by animateColorAsState(if (checked) colors.accent else Color.Transparent, tween(fast), label = "background")
    val fill by animateColorAsState(if (checked) colors.primary else Color.Transparent, tween(medium, easing = FormEasing), label = "fill")
    val stroke by animateColorAsState(if (checked) colors.primary else colors.mutedForeground, tween(medium, easing = FormEasing), label = "stroke")
    Box(modifier = modifier.fillMaxWidth().background(background)) {
        Row(
            modifier = Modifier.fillMaxWidth().then(if (editing) Modifier.clearAndSetSemantics {} else Modifier),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier
                    .width(indicatorWidth)
                    .clipToBounds()
                    .graphicsLayer { alpha = indicatorAlpha },
                contentAlignment = Alignment.CenterStart,
            ) {
                Box(
                    modifier = Modifier
                        .padding(start = 16.dp)
                        .size(22.dp)
                        .scale(indicatorScale)
                        .background(fill, CircleShape)
                        .border(1.5.dp, stroke, CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        PhosphorIcons.Bold.Check,
                        contentDescription = null,
                        modifier = Modifier.scale(checkScale),
                        size = 14.dp,
                        tint = colors.primaryForeground,
                    )
                }
            }
            Row(
                modifier = Modifier.weight(1f),
                verticalAlignment = Alignment.CenterVertically,
                content = content,
            )
        }
        if (editing) {
            Spacer(
                modifier = Modifier
                    .matchParentSize()
                    .toggleable(
                        value = checked,
                        interactionSource = remember { MutableInteractionSource() },
                        indication = null,
                        role = Role.Checkbox,
                        onValueChange = { context.toggle(value) },
                    )
                    .semantics { if (contentDescription != null) this.contentDescription = contentDescription },
            )
        }
    }
}

@Composable
fun SelectionListBar(
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    val context = selectionList()
    FloatingBar(
        visible = context.editing,
        maxWidth = 448.dp,
        contentPadding = PaddingValues(horizontal = 6.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        modifier = modifier,
    ) {
        CompositionLocalProvider(LocalSelectionList provides context) {
            content()
        }
    }
}

@Composable
fun RowScope.SelectionListCount(
    modifier: Modifier = Modifier,
    text: String? = null,
) {
    val count = selectionList().selected.size
    BasicText(
        text ?: if (count == 0) "Select items" else "$count selected",
        modifier = modifier.weight(1f),
        style = Typography.sm.copy(
            color = EsuissTheme.colors.foreground,
            fontWeight = FontWeight.Medium,
            textAlign = TextAlign.Center,
            fontFeatureSettings = "tnum",
        ),
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/interactions/FloatingBar.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/interactions/FloatingBar.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SelectionList, SelectionListTrigger, SelectionListItem, SelectionListBar, SelectionListCount } from "@/components/interactions/selection-list"
```

```tsx
<NotesExample />
```

Wrap the list in `SelectionList`, add a `SelectionListTrigger` that toggles editing (it reads `Select` and `Done` by default), and give each `SelectionListItem` a unique `value`. Put your buttons and a `SelectionListCount` in `SelectionListBar`.

Editing and the selection can be uncontrolled with `defaultEditing` and `defaultValue`, or controlled with `editing`, `onEditingChange`, `value` and `onValueChange`. `useSelectionList` returns `editing`, `selected`, `toggle`, `selectAll` and `clear` for custom actions. Outside editing mode, the row's children receive presses as usual.

## Examples

### Editing

Starts in editing mode with two notes selected.

![Preview](https://ui.suiss.app/previews/interactions-selection-list--editing.light.webp)

```tsx
import { CaretRightIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: "books", title: "Book list", preview: "Three novels for the summer" },
  { id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]

type Note = (typeof initialNotes)[number]

function NoteRows({
  notes,
  onOpen,
}: {
  notes: Note[]
  onOpen: (note: Note) => void
}) {
  const { editing } = useSelectionList()
  return (
    <View className="overflow-hidden rounded-2xl border border-border">
      {notes.map((note, index) => (
        <SelectionListItem
          key={note.id}
          value={note.id}
          className={index > 0 ? "border-t border-border" : undefined}
        >
          <Pressable
            onPress={() => onOpen(note)}
            className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:opacity-80"
          >
            <View className="min-w-0 flex-1">
              <Text numberOfLines={1} className="font-semibold">
                {note.title}
              </Text>
              <Text numberOfLines={1} className="text-sm text-muted-foreground">
                {note.preview}
              </Text>
            </View>
            <Icon
              as={CaretRightIcon}
              className={
                editing
                  ? "size-4 shrink-0 text-muted-foreground opacity-0"
                  : "size-4 shrink-0 text-muted-foreground"
              }
            />
          </Pressable>
        </SelectionListItem>
      ))}
    </View>
  )
}

function SelectAllButton() {
  const { selected, selectAll, clear } = useSelectionList()
  const all = selected.length > 0
  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4"
      onPress={all ? clear : selectAll}
    >
      <Text>{all ? "Deselect All" : "Select All"}</Text>
    </Button>
  )
}

function DeleteButton({ onDelete }: { onDelete: (ids: string[]) => void }) {
  const { selected, setEditing } = useSelectionList()
  const disabled = selected.length === 0
  return (
    <Button
      variant="plain"
      size="lg"
      className="px-4"
      disabled={disabled}
      onPress={() => {
        onDelete(selected)
        setEditing(false)
      }}
    >
      <Text
        className={
          disabled ? "text-destructive opacity-50" : "text-destructive"
        }
      >
        Delete
      </Text>
    </Button>
  )
}

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Tap Select to choose notes.")
  return {
    notes,
    log,
    setLog,
    remove: (ids: string[]) => {
      setNotes((current) => current.filter((note) => !ids.includes(note.id)))
      setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
    },
    reset: () => {
      setNotes(initialNotes)
      setLog("Tap Select to choose notes.")
    },
  }
}

function Footer({ log, onReset }: { log: string; onReset: () => void }) {
  return (
    <View className="flex-row items-center justify-between gap-3">
      <Text
        role="status"
        className="min-w-0 flex-1 text-sm text-muted-foreground"
      >
        {log}
      </Text>
      <Button size="sm" variant="secondary" onPress={onReset}>
        <Text>Reset</Text>
      </Button>
    </View>
  )
}

function NotesExample({
  defaultEditing,
  defaultValue,
}: {
  defaultEditing?: boolean
  defaultValue?: string[]
}) {
  const { notes, log, setLog, remove, reset } = useNotes()
  return (
    <SelectionList
      defaultEditing={defaultEditing}
      defaultValue={defaultValue}
      className="w-full gap-3"
    >
      <View className="flex-row items-center justify-between">
        <Text variant="h1">Notes</Text>
        <SelectionListTrigger />
      </View>
      <NoteRows
        notes={notes}
        onOpen={(note) => setLog(`Opened ${note.title}`)}
      />
      <Footer log={log} onReset={reset} />
      <SelectionListBar accessibilityLabel="Selection actions">
        <SelectAllButton />
        <SelectionListCount />
        <DeleteButton onDelete={remove} />
      </SelectionListBar>
    </SelectionList>
  )
}

export function SelectionListEditingExample() {
  return (
    <NotesExample defaultEditing defaultValue={["trip", "meeting"]} />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct SelectionListEditingExample: View {
  var body: some View {
    SelectionListNotes(editing: true, selection: ["trip", "meeting"])
  }
}

struct SelectionListNote: Identifiable {
  let id: String
  let title: String
  let preview: String
}

let selectionListInitialNotes = [
  SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
  SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
  SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]

let selectionListIdleLog = "Tap Select to choose notes."

struct SelectionListRows: View {
  let notes: [SelectionListNote]
  let editing: Bool
  let onOpen: (SelectionListNote) -> Void

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(spacing: 0) {
      ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
        SelectionListItem(value: note.id) {
          Button {
            onOpen(note)
          } label: {
            HStack(spacing: 12) {
              VStack(alignment: .leading, spacing: 0) {
                Text(verbatim: note.title)
                  .textStyle(.base, weight: .semibold)
                  .foregroundStyle(EsuissColor.foreground)
                  .lineLimit(1)
                Text(verbatim: note.preview)
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .lineLimit(1)
              }
              .frame(maxWidth: .infinity, alignment: .leading)
              Ph.caretRight.regular
                .frame(width: 16, height: 16)
                .foregroundStyle(EsuissColor.mutedForeground)
                .opacity(editing ? 0 : 1)
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .contentShape(Rectangle())
          }
          .buttonStyle(SelectionListRowStyle())
        }
        .overlay(alignment: .top) {
          if index > 0 {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
  }
}

struct SelectionListRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

struct SelectionListFooter: View {
  let log: String
  let onReset: () -> Void

  var body: some View {
    HStack(spacing: 12) {
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
      Button("Reset", action: onReset)
        .buttonStyle(.variant(.secondary, size: .sm))
    }
  }
}

struct SelectionListStage<Content: View, Bar: View>: View {
  @ViewBuilder let content: () -> Content
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      content()
    }
    .frame(maxHeight: .infinity, alignment: .top)
    .overlay(alignment: .bottom) {
      bar()
        .padding(.horizontal, -16)
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SelectionListNotes: View {
  @State private var editing: Bool
  @State private var selection: Set<String>
  @State private var notes = selectionListInitialNotes
  @State private var log = selectionListIdleLog

  init(editing: Bool = false, selection: Set<String> = []) {
    _editing = State(initialValue: editing)
    _selection = State(initialValue: selection)
  }

  var body: some View {
    SelectionList(isEditing: $editing, selection: $selection) {
      SelectionListStage {
        HStack {
          Text("Notes")
            .textVariant(.h1)
          Spacer()
          SelectionListTrigger()
        }
        SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
        SelectionListFooter(log: log) {
          notes = selectionListInitialNotes
          log = selectionListIdleLog
        }
      } bar: {
        SelectionListBar { list in
          Button(list.count > 0 ? "Deselect All" : "Select All") {
            if list.count > 0 { list.clear() } else { list.selectAll() }
          }
          .buttonStyle(.variant(.plain, size: .lg))
          SelectionListCount()
          Button {
            let ids = selection
            notes.removeAll { ids.contains($0.id) }
            log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
            selection = []
            list.setEditing(false)
          } label: {
            Text("Delete")
              .foregroundStyle(EsuissColor.destructive)
          }
          .buttonStyle(.variant(.plain, size: .lg))
          .disabled(list.count == 0)
        }
        .accessibilityLabel(Text("Selection actions"))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun SelectionListEditingExample() {
    SelectionListNotes(defaultEditing = true, defaultValue = listOf("trip", "meeting"))
}

class SelectionListNote(val id: String, val title: String, val preview: String)

val selectionListInitialNotes = listOf(
    SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
    SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
    SelectionListNote("books", "Book list", "Three novels for the summer"),
    SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
    SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
    SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)

@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        notes.forEachIndexed { index, note ->
            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
            SelectionListItem(value = note.id, contentDescription = note.title) {
                SelectionListRowContent(note, editing) { onOpen(note) }
            }
        }
    }
}

@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            log,
            modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
        Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
    }
}

@Composable
fun SelectionListHeader(title: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(title, variant = TextVariant.H1)
        SelectionListTrigger()
    }
}

fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"

@Composable
fun SelectionListNotes(defaultEditing: Boolean = false, defaultValue: List<String> = emptyList()) {
    val colors = EsuissTheme.colors
    var notes by remember { mutableStateOf(selectionListInitialNotes) }
    var log by rememberSaveable { mutableStateOf(SelectionListHint) }
    var editing by rememberSaveable { mutableStateOf(defaultEditing) }
    var selected by rememberSaveable { mutableStateOf(defaultValue) }
    SelectionList(
        editing = editing,
        onEditingChange = { editing = it },
        value = selected,
        onValueChange = { selected = it },
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            SelectionListHeader("Notes")
            SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
            SelectionListFooter(log) {
                notes = selectionListInitialNotes
                log = SelectionListHint
            }
        }
        SelectionListBar {
            val any = this@SelectionList.selected.isNotEmpty()
            Button(
                if (any) "Deselect All" else "Select All",
                onClick = { if (any) clear() else selectAll() },
                modifier = Modifier.padding(horizontal = 4.dp),
                variant = ButtonVariant.Plain,
                size = ButtonSize.Lg,
            )
            SelectionListCount()
            Button(
                onClick = {
                    val ids = this@SelectionList.selected
                    notes = notes.filterNot { it.id in ids }
                    log = selectionListDeleted(ids.size)
                    setEditing(false)
                },
                modifier = Modifier.padding(horizontal = 4.dp),
                variant = ButtonVariant.Plain,
                size = ButtonSize.Lg,
                enabled = any,
            ) {
                Text("Delete", style = Typography.base.copy(color = colors.destructive))
            }
        }
    }
}
```

### Icon actions

A mail list whose bar holds icon-only actions.

![Preview](https://ui.suiss.app/previews/interactions-selection-list--icon-actions.light.webp)

```tsx
import { ArchiveIcon, CaretRightIcon, FolderSimpleIcon, TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { SelectionList, SelectionListBar, SelectionListCount, SelectionListItem, SelectionListTrigger, useSelectionList } from "@/components/interactions/selection-list"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: "books", title: "Book list", preview: "Three novels for the summer" },
  { id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry" },
]

type Note = (typeof initialNotes)[number]

function NoteRows({
  notes,
  onOpen,
}: {
  notes: Note[]
  onOpen: (note: Note) => void
}) {
  const { editing } = useSelectionList()
  return (
    <View className="overflow-hidden rounded-2xl border border-border">
      {notes.map((note, index) => (
        <SelectionListItem
          key={note.id}
          value={note.id}
          className={index > 0 ? "border-t border-border" : undefined}
        >
          <Pressable
            onPress={() => onOpen(note)}
            className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-3 active:opacity-80"
          >
            <View className="min-w-0 flex-1">
              <Text numberOfLines={1} className="font-semibold">
                {note.title}
              </Text>
              <Text numberOfLines={1} className="text-sm text-muted-foreground">
                {note.preview}
              </Text>
            </View>
            <Icon
              as={CaretRightIcon}
              className={
                editing
                  ? "size-4 shrink-0 text-muted-foreground opacity-0"
                  : "size-4 shrink-0 text-muted-foreground"
              }
            />
          </Pressable>
        </SelectionListItem>
      ))}
    </View>
  )
}

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Tap Select to choose notes.")
  return {
    notes,
    log,
    setLog,
    remove: (ids: string[]) => {
      setNotes((current) => current.filter((note) => !ids.includes(note.id)))
      setLog(`Deleted ${ids.length} ${ids.length === 1 ? "note" : "notes"}`)
    },
    reset: () => {
      setNotes(initialNotes)
      setLog("Tap Select to choose notes.")
    },
  }
}

function Footer({ log, onReset }: { log: string; onReset: () => void }) {
  return (
    <View className="flex-row items-center justify-between gap-3">
      <Text
        role="status"
        className="min-w-0 flex-1 text-sm text-muted-foreground"
      >
        {log}
      </Text>
      <Button size="sm" variant="secondary" onPress={onReset}>
        <Text>Reset</Text>
      </Button>
    </View>
  )
}

function MailExample() {
  const { notes, log, setLog, remove, reset } = useNotes()
  const [selected, setSelected] = React.useState<string[]>([])
  const disabled = selected.length === 0

  const act = (verb: string) => setLog(`${verb} ${selected.join(", ")}`)

  return (
    <SelectionList
      value={selected}
      onValueChange={setSelected}
      className="w-full gap-3"
    >
      <View className="flex-row items-center justify-between">
        <Text variant="h1">Inbox</Text>
        <SelectionListTrigger />
      </View>
      <NoteRows
        notes={notes}
        onOpen={(note) => setLog(`Opened ${note.title}`)}
      />
      <Footer log={log} onReset={reset} />
      <SelectionListBar accessibilityLabel="Selection actions">
        <Button
          variant="plain"
          size="icon-lg"
          accessibilityLabel="Archive"
          disabled={disabled}
          onPress={() => act("Archived")}
        >
          <Icon as={ArchiveIcon} className="size-5.5" />
        </Button>
        <Button
          variant="plain"
          size="icon-lg"
          accessibilityLabel="Move"
          disabled={disabled}
          onPress={() => act("Moved")}
        >
          <Icon as={FolderSimpleIcon} className="size-5.5" />
        </Button>
        <SelectionListCount />
        <Button
          variant="plain"
          size="icon-lg"
          accessibilityLabel="Delete"
          disabled={disabled}
          onPress={() => {
            remove(selected)
            setSelected([])
          }}
        >
          <Icon
            as={TrashIcon}
            className={
              disabled
                ? "size-5.5 text-destructive opacity-50"
                : "size-5.5 text-destructive"
            }
          />
        </Button>
      </SelectionListBar>
    </SelectionList>
  )
}

export function SelectionListIconActionsExample() {
  return (
    <MailExample />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct SelectionListNote: Identifiable {
  let id: String
  let title: String
  let preview: String
}

let selectionListInitialNotes = [
  SelectionListNote(id: "groceries", title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SelectionListNote(id: "trip", title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SelectionListNote(id: "books", title: "Book list", preview: "Three novels for the summer"),
  SelectionListNote(id: "meeting", title: "Meeting notes", preview: "Ship the beta on Friday"),
  SelectionListNote(id: "gifts", title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SelectionListNote(id: "recipes", title: "Recipes", preview: "Lemon pasta and a quick curry"),
]

let selectionListIdleLog = "Tap Select to choose notes."

struct SelectionListRows: View {
  let notes: [SelectionListNote]
  let editing: Bool
  let onOpen: (SelectionListNote) -> Void

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(spacing: 0) {
      ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
        SelectionListItem(value: note.id) {
          Button {
            onOpen(note)
          } label: {
            HStack(spacing: 12) {
              VStack(alignment: .leading, spacing: 0) {
                Text(verbatim: note.title)
                  .textStyle(.base, weight: .semibold)
                  .foregroundStyle(EsuissColor.foreground)
                  .lineLimit(1)
                Text(verbatim: note.preview)
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .lineLimit(1)
              }
              .frame(maxWidth: .infinity, alignment: .leading)
              Ph.caretRight.regular
                .frame(width: 16, height: 16)
                .foregroundStyle(EsuissColor.mutedForeground)
                .opacity(editing ? 0 : 1)
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .contentShape(Rectangle())
          }
          .buttonStyle(SelectionListRowStyle())
        }
        .overlay(alignment: .top) {
          if index > 0 {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
  }
}

struct SelectionListRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

struct SelectionListFooter: View {
  let log: String
  let onReset: () -> Void

  var body: some View {
    HStack(spacing: 12) {
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
      Button("Reset", action: onReset)
        .buttonStyle(.variant(.secondary, size: .sm))
    }
  }
}

struct SelectionListStage<Content: View, Bar: View>: View {
  @ViewBuilder let content: () -> Content
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      content()
    }
    .frame(maxHeight: .infinity, alignment: .top)
    .overlay(alignment: .bottom) {
      bar()
        .padding(.horizontal, -16)
    }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SelectionListMail: View {
  @State private var editing = false
  @State private var selection: Set<String> = []
  @State private var notes = selectionListInitialNotes
  @State private var log = selectionListIdleLog

  var selectedIDs: String {
    notes.map(\.id).filter { selection.contains($0) }.joined(separator: ", ")
  }

  var body: some View {
    let disabled = selection.isEmpty
    SelectionList(isEditing: $editing, selection: $selection) {
      SelectionListStage {
        HStack {
          Text("Inbox")
            .textVariant(.h1)
          Spacer()
          SelectionListTrigger()
        }
        SelectionListRows(notes: notes, editing: editing) { log = "Opened \($0.title)" }
        SelectionListFooter(log: log) {
          notes = selectionListInitialNotes
          log = selectionListIdleLog
        }
      } bar: {
        SelectionListBar {
          Button {
            log = "Archived \(selectedIDs)"
          } label: {
            Ph.archive.regular.frame(width: 22, height: 22)
          }
          .buttonStyle(.variant(.plain, size: .iconLg))
          .accessibilityLabel(Text("Archive"))
          .disabled(disabled)
          Button {
            log = "Moved \(selectedIDs)"
          } label: {
            Ph.folderSimple.regular.frame(width: 22, height: 22)
          }
          .buttonStyle(.variant(.plain, size: .iconLg))
          .accessibilityLabel(Text("Move"))
          .disabled(disabled)
          SelectionListCount()
          Button {
            let ids = selection
            notes.removeAll { ids.contains($0.id) }
            log = "Deleted \(ids.count) \(ids.count == 1 ? "note" : "notes")"
            selection = []
          } label: {
            Ph.trash.regular
              .frame(width: 22, height: 22)
              .foregroundStyle(EsuissColor.destructive)
          }
          .buttonStyle(.variant(.plain, size: .iconLg))
          .accessibilityLabel(Text("Delete"))
          .disabled(disabled)
        }
        .accessibilityLabel(Text("Selection actions"))
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Archive
import com.adamglin.phosphoricons.regular.FolderSimple
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListCount
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

class SelectionListNote(val id: String, val title: String, val preview: String)

val selectionListInitialNotes = listOf(
    SelectionListNote("groceries", "Groceries", "Milk, eggs, bread, coffee"),
    SelectionListNote("trip", "Trip ideas", "Lisbon, Porto, the coast road"),
    SelectionListNote("books", "Book list", "Three novels for the summer"),
    SelectionListNote("meeting", "Meeting notes", "Ship the beta on Friday"),
    SelectionListNote("gifts", "Gift ideas", "Headphones, a plant, a scarf"),
    SelectionListNote("recipes", "Recipes", "Lemon pasta and a quick curry"),
)

@Composable
fun SelectionListRows(notes: List<SelectionListNote>, editing: Boolean, onOpen: (SelectionListNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        notes.forEachIndexed { index, note ->
            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
            SelectionListItem(value = note.id, contentDescription = note.title) {
                SelectionListRowContent(note, editing) { onOpen(note) }
            }
        }
    }
}

@Composable
fun SelectionListFooter(log: String, onReset: () -> Unit) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            log,
            modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
        Button("Reset", onClick = onReset, variant = ButtonVariant.Secondary, size = ButtonSize.Sm)
    }
}

@Composable
fun SelectionListHeader(title: String) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(title, variant = TextVariant.H1)
        SelectionListTrigger()
    }
}

fun selectionListDeleted(count: Int): String = "Deleted $count ${if (count == 1) "note" else "notes"}"

@Composable
fun SelectionListMail() {
    val colors = EsuissTheme.colors
    var notes by remember { mutableStateOf(selectionListInitialNotes) }
    var log by rememberSaveable { mutableStateOf(SelectionListHint) }
    var editing by rememberSaveable { mutableStateOf(false) }
    var selected by rememberSaveable { mutableStateOf(emptyList<String>()) }
    val enabled = selected.isNotEmpty()
    SelectionList(
        editing = editing,
        onEditingChange = { editing = it },
        value = selected,
        onValueChange = { selected = it },
        modifier = Modifier.fillMaxWidth(),
    ) {
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            SelectionListHeader("Inbox")
            SelectionListRows(notes, editing) { log = "Opened ${it.title}" }
            SelectionListFooter(log) {
                notes = selectionListInitialNotes
                log = SelectionListHint
            }
        }
        SelectionListBar {
            SelectionListBarIcon(PhosphorIcons.Regular.Archive, "Archive", enabled) { log = "Archived ${selected.joinToString(", ")}" }
            SelectionListBarIcon(PhosphorIcons.Regular.FolderSimple, "Move", enabled) { log = "Moved ${selected.joinToString(", ")}" }
            SelectionListCount()
            SelectionListBarIcon(
                PhosphorIcons.Regular.Trash,
                "Delete",
                enabled,
                tint = colors.destructive,
            ) {
                val ids = selected
                notes = notes.filterNot { it.id in ids }
                log = selectionListDeleted(ids.size)
                selected = emptyList()
            }
        }
    }
}
```

## API Reference

### SelectionList

The root that holds the editing state and selection.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `editing` | `boolean` | - | Controls editing mode. |
| `defaultEditing` | `boolean` | `false` | Whether editing starts on when uncontrolled. |
| `onEditingChange` | `(editing: boolean) => void` | - | Called when editing turns on or off. |
| `value` | `string[]` | - | Selected item values when controlled. |
| `defaultValue` | `string[]` | `EMPTY` | Initially selected values when uncontrolled. |
| `onValueChange` | `(value: string[]) => void` | - | Called with the new selection. |

Also accepts the props of `ViewProps`.

### SelectionListTrigger

A plain button that toggles editing.

Also accepts the props of `React.ComponentProps<typeof Button>`.

### SelectionListItem

A row that toggles its `value` while editing.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | Required | The value this row adds to the selection. |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

### SelectionListBar

A floating toolbar shown while editing.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of a portal host to render into. |

Also accepts the props of `ViewProps`.

### SelectionListCount

Text showing the number of selected items.

Also accepts the props of `React.ComponentProps<typeof Text>`.

## Accessibility

- While editing, rows have the `checkbox` role and expose their `checked` state; otherwise the row wrapper is not focusable and its children are.
- The trigger exposes a `selected` state while editing.
- The bar has the `toolbar` role.
- `SelectionListCount` is a polite live region, reading `Select items` or `n selected` by default.

## Notes

- Selected rows use `bg-accent`; checked circles use `bg-primary` with a `primary-foreground` check.
- The bar uses `bg-card`, or `bg-muted` in dark mode, and is up to `max-w-md` wide.
- Animations respect the system reduce motion setting.

