Browse all of Kata

Pickers, selects, and sheets

Source docs/design/patterns/sheet-picker.mdMarkdown

On this page

Chips, sheets, and the native picker — chosen by option count.

Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.

Real forms are largely pickers, and the blind test found the system had none. Konjo ships BottomSheet (a @gorhom/bottom-sheet wrapper), RankPicker (inline) and RankPickerSheet (modal); these are the rules they follow.

Not BeltRankPicker. It is deprecated and still has five consumers. It lists the nineteen Cuong Nhu ranks from a constant, which rank forbids outright — a screen renders whatever ladder the dojo has, and nineteen is a Cuong Nhu number. Reach for RankPicker, which takes the ladder as data.

Choose the control by the size of the choice set:

Options Control
2–6, short labels Chips, wrapping, inline. No sheet. The choice is visible without a tap.
7–20 Sheet with a plain list of rows
21+, or the user knows what they want Sheet with a search field pinned at the top
A date or a time The native picker. Never rebuild one.

A chip set is always preferred where it fits, because it shows the options rather than hiding them behind a tap that also hides the field the user is filling in.

Sheet anatomy:

        ▁▁▁▁                     grabber, controlSize.grabber*, radius.pill, border.control
Choose a rank                    title 22/700 · left-aligned, never centred
                            ↕16
[ ⌕ Search…              ]       only above 20 options; h44, border.control
                            ↕12
●  Green                     ✓   row ≥64pt · rowTitle · hairline inset · check on the right
●  1 Brown Stripe                the selected row carries a check, not a fill —
●  2 Brown Stripes               a filled row inside a sheet reads as a hero

radius.sheet 24 on the top corners only. The scrim behind is overlay.dim, and it is what separates the sheet from the page — there is no shadow. The sheet is dismissible by swipe and by tapping the scrim, and both must leave the form's state untouched.

A picker never validates. It returns a value; the field it feeds validates on blur like any other. A sheet that shows its own error message is a sheet doing the form's job.

Multi-select shows a count in the trigger ("3 selected") and a checkmark per row; the sheet stays open. Single-select closes on tap — no confirm button, because the tap is the confirmation.

The native date and time picker

"Never rebuild one" leaves open what hosts it, and a wheel has no row to tap, so the close-on-selection rule above does not reach it.

  • It sits in the app's own BottomSheet, with the same grabber, the same left-aligned title ("Choose a date"), the same radius.sheet corners and the same scrim — so it is the same object as every other picker on the screen rather than a system surface that arrives from nowhere. On Android, where the platform dialog is the strong convention, use it.
  • The wheel is inline inside that sheet, never a nested modal.
  • Confirmation is the selection: changing the value commits it and closes. There is no Done button — the same reason a row list has none.
  • Dismissing without choosing leaves the field untouched, including when a wheel has been spun but not settled.
  • The trigger shows the value in the content-format form, never the platform's own string, so a date reads the same everywhere in the app.
  • Bounds come from the product, not the design. A field with no stated bounds accepts any date; if the record has limits, they are a product fact and belong in product-facts.

An option that cannot be chosen

Sooner or later a list has a row the person may not pick — a rank below the one they hold, a class already full, an instructor who has left. The system has no disabled text colour, on purpose: text.quaternary is not a text colour, and greying a row out is how you make something unreadable rather than unavailable.

So an unavailable row is not dimmed. It stays at full contrast and says why, in caption / text.secondary, on the row:

●  Green Belt                    Current rank      not selectable, full contrast
●  Brown Belt                                      selectable
●  Black Belt              Requires a test         not selectable, reason stated
  • accessibilityState={{ disabled: true }} so it is announced as unavailable rather than silently ignoring taps.
  • A reason is mandatory. A row that does nothing and does not say why reads as a bug, and the person will tap it three times before deciding the app is broken.
  • If most rows are unavailable, filter instead. A list where four of nineteen can be chosen is a list that should have been five rows long, with a line above it saying what was excluded.

The sheet's own measurements

The anatomy sketch shows a sheet that has a search field. Two of the three pickers on a typical form do not, and the gaps for that case were never written down.

        ▁▁▁▁                       grabber · controlSize.grabber* · radius.pill · border.control
                          ↕16
Choose a rank                      title 22/700, left-aligned
                          ↕16      ← to the first ROW when there is no search field
●  Green Belt                      (with a search field: ↕16 to it, then ↕12 to the first row)
  • spacing.page 20 horizontal, the same gutter as a screen — a sheet is a surface, not a card, and its content lines up with the content behind it.
  • Grabber to title 16. Title to the first row 16, or to a search field 16 and then 12 from the search field to the rows.
  • The bottom is insets.bottom + spacing.section, like a modal — there is no tab bar under a sheet.
  • A supporting line under the title — the discard sheet has one — is caption / text.secondary, 8 under the title, and the rows start 16 below it.

So the discard sheet, in full:

        ▁▁▁▁
                          ↕16
Discard this promotion?            title 22/700
                           ↕8
You'll lose what you've typed.     caption · text.secondary
                          ↕16
Keep editing                       row ≥ controlHeight.row, the SAFE option first
Discard                            row, accent.red

A sheet whose options come from the server

Every picker on a real screen — students, ranks, instructors — loads its rows. states.md scopes itself to screens and to sections; a sheet is neither, so this is its own rule.

A sheet always opens. It never waits for its data before appearing, and a slow load never leaves the person tapping a trigger that seems dead. The sheet rises, and the rows arrive in it.

What the sheet shows
Loading The grabber, the title and the search field render immediately — they do not depend on the rows. Under a second, the row area is blank. Past a second, three or four skeleton rows at controlHeight.row. Never a centred spinner in an empty sheet.
Empty The title stays, the search field is removed — there is nothing to search — and the row area carries the empty anatomy at section weight: what would be here, and why it is not. No filled primary.
Failed Title stays. In the row area: "Couldn't load students", plain words about what failed, and [ Try again ] as a bordered secondary — the screen behind the sheet still owns the filled primary.
Empty after a search Rows go, the search field and its query stay, and the message names the query: "No students match 'torez'." Same rule as search and filter.
  • The sheet keeps its height across states where it can, so it does not resize under the person's thumb as rows arrive.
  • Dismissing during any state is always allowed and leaves the field untouched — a picker that traps someone while it retries is worse than one that failed.
  • Announce the outcome: accessibilityLiveRegion="polite" on the row area, so a screen-reader user learns the rows arrived, or did not.
  • A failure inside a sheet never fails the form behind it. The field simply has no value yet, and validation treats it as empty.