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-alignedtitle("Choose a date"), the sameradius.sheetcorners 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.page20 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.