# XAUI Native — full documentation
> Every @xaui/native component page, in one file. The index is https://ui.xtartapp.com/llms.txt.
# Accordion
A list of rows that open. Replaces the legacy `ExpansionPanel`.
## Import
```tsx
import { Accordion } from '@xaui/native/accordion'
```
## Anatomy
```tsx
…
…
```
- **`Accordion`** — the container. Holds what is open, resolves every slot's style, and
draws the separators between its children.
- **`Accordion.Item`** — one row. It owns the height animation, because the height that
changes is the row's.
- **`Accordion.Trigger`** — the part you press. Carries `expanded` for a screen reader.
- **`Accordion.Indicator`** — the chevron, turning with the panel.
- **`Accordion.Content`** — the panel. Mounted when open, absent when not.
## Usage
### Basic
```tsx
Livraison
Sous trois jours ouvrés.
```
A stringifiable child of the trigger is wrapped in a `Text` for you (R3) and takes the
type the recipe put on the row. Anything else is yours to place — the trigger is a row of
views, and a bare string in one is a crash on React Native.
### Several at a time
```tsx
…
```
The value becomes a list, in the order the rows were opened.
### Always one open
```tsx
…
```
Pressing the open row refuses rather than closing it, and `onValueChange` never fires for
a change that did not happen. That is a set of tabs wearing an accordion.
### A row that paints its own state
```tsx
{({ isExpanded }) => (
<>
Facturation
…
>
)}
```
The escape hatch for a row whose whole appearance changes when it opens, without wiring
`useAccordionItem` yourself.
### Controlled
```tsx
const [value, setValue] = useState('a')
setValue(next as string)}>…
```
Which half is controlled is decided on the first render and then held — a component that
changes hands mid-life produces a bug nobody can read from the call site.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
### Everything else goes through `style`
A gradient container, a tinted shadow, a transform — `style` is the last word.
## Props
### `Accordion`
| prop | type | default | description |
| --------------- | --------------------------------- | -------- | ---------------------------------------- |
| `variant` | `AccordionVariant` | `ghost` | The `Card`'s four levels |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Row inset, gap and type |
| `radius` | `RadiusKey` | — | Overrides the container radius |
| `color` | `string` | — | The tint (R7) — a raw value |
| `selectionMode` | `'single' \| 'multiple'` | `single` | One row open, or as many as asked |
| `value` | `string \| readonly string[]` | — | Controlled |
| `defaultValue` | `string \| readonly string[]` | — | Uncontrolled |
| `onValueChange` | `(value: AccordionValue) => void` | — | Fires only on an actual change |
| `isDisabled` | `boolean` | `false` | Dims the container, stops every row |
| `isCollapsible` | `boolean` | `true` | Whether the open row can be closed |
| `hasSeparator` | `boolean` | `true` | The hairline between rows |
| `asChild` | `boolean` | `false` | Renders the caller's element as the root |
### `Accordion.Item`
`value` (required), `isDisabled`. `children` may be a function taking
`{ isExpanded, isDisabled, value }`.
### `Accordion.Trigger`
Everything `Pressable` takes, plus `ViewStyle` as props. `asChild` renders the caller's
element as the row.
### `Accordion.Indicator`
`as`, `size`, `color`. Defaults to the chevron the library ships from `system/icon`.
### `Accordion.Content`
Everything `View` takes, plus `ViewStyle` as props.
## Motion
**The height is never measured.** The panel is mounted or it is not, and Reanimated's
layout transition animates the row between the two — `LinearTransition.springify()` on
The reference implementation's numbers, damping 140 against stiffness **1600**. Stiffer than the chevron's 1000
on purpose: a height is a longer distance than a rotation, and at the chevron's stiffness
the same damping makes a long panel take almost half a second to settle.
Measuring it ourselves would mean a hidden pass on every open, and a panel whose content
grows afterwards — an image loading, a list filling — would be stuck at the height it had
when it was measured. `overflow: 'hidden'` on the row is what turns the mounted panel into
one unrolling rather than content drawn outside its row from the first frame.
The container carries the same transition. Without it the accordion's own height jumps to
its new total in one frame while the rows inside it are still animating.
**The content fades**, 200 ms each way. The height is already moving underneath it, and
two things travelling at once reads as the panel fighting itself.
**The chevron** turns 0 → −180° on the `Select`'s spring — damping 140, stiffness 1000,
mass 4. It is a worklet, so it keeps turning while the panel's content mounts.
## The four levels
| ours | The reference implementation | fill |
| ----------- | ---------------------------- | ------------------ |
| `primary` | `surface` | `surface` |
| `secondary` | — | `surfaceSecondary` |
| `tertiary` | — | a border, no fill |
| `ghost` | `default` | none — the default |
The tokens are the `Card`'s — an accordion in `primary` **is** a card with rows in it, and
two containers that look alike but are declared apart drift. Only the names differ, and
they differ on purpose: the ladder descends in one direction, which `default` sitting in
the middle of the `Card`'s order does not.
## The corner
It moves with `size`, as the `Card`'s does, and sits one level below the `Card`'s at every
step:
| `size` | corner | the `Card` at the same size |
| ------ | ------ | --------------------------- |
| `xs` | 9 | 12 |
| `sm` | 12 | 18 |
| `md` | 18 | 24 |
| `lg` | 24 | 36 |
A card wraps its content with padding on all four sides, so a large corner curves through
empty space. An accordion's rows run edge to edge, and the same corner curves through the
first and last row's own text.
`radius` overrides it, on both layers at once.
## Two layers on the root
The root renders one view inside itself, and it is the only place in the library where a
root does. A single layer cannot both cast a shadow and clip its children on iOS —
`overflow: 'hidden'` sets `masksToBounds`, which takes the layer's own shadow with it — and
this component needs both: `primary` is lifted, and a pressed row has to be cut against the
card's rounded corner rather than painting over it, square, for as long as a finger is on
it.
So the outer layer carries the shadow, the border and the corner; the inner one carries the
same corner and the clip. Both take the layout transition, because without it on the outer
one the accordion's height jumps to its new total a frame ahead of the rows inside it.
## The separators
Drawn by the root, **between** its children — never by a row. A row that drew its own
would draw one under the last item too, and every accordion would start by hiding it.
They are built from `Children.toArray`, which drops nulls, so a conditionally rendered row
cannot leave a hairline hanging where nothing is.
In `primary`, `secondary` and `tertiary` the container is inset from its own edge and the
separators are inset with it. `ghost` has no edge to be inset from, so its rows run the
full width and the hairline runs with them — the difference between a list on a page and a
list in a box.
## Accessibility
The trigger is a `button` carrying `expanded`, which is the only thing telling a screen
reader whether the panel under it is open: the chevron says nothing out loud.
## Migration from `@xaui/native-legacy`
The legacy components are `ExpansionPanel` and `ExpansionPanelItem`.
| Legacy | v1 |
| -------------------------- | ---------------------------------------------- |
| `` | `` |
| `` | `` |
| `title="…"` | a stringifiable child of `` |
| the item's `children` | `` |
| `isExpanded` per item | `value` / `defaultValue` on the root |
| `allowMultiple` | `selectionMode="multiple"` |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `customAppearance={{ … }}` | `style` on the slot that key named |
**The open state moves to the root.** Legacy asked each item whether it was open, which is
what made "only one at a time" the caller's problem. One value on the container is what
`selectionMode` needs to mean anything.
# AgendaCalendar
One week, and what is on it.
## Import
```tsx
import { AgendaCalendar } from '@xaui/native/agenda-calendar'
```
`AgendaCalendar.Week` pages on a horizontal drag, which runs on `react-native-gesture-handler`
— an **optional** peer of this package. Leaving that default on needs the library installed
and a `` above the strip; `swipeable={false}` removes both.
## Anatomy
```tsx
Today
```
| slot | what it is |
| ------------------------------- | ----------------------------------------------- |
| `AgendaCalendar` | The card, the state, the resolved style |
| `AgendaCalendar.Header` | The row above the strip |
| `AgendaCalendar.Title` | The month the week is in |
| `AgendaCalendar.Nav` | The cluster on the trailing end |
| `AgendaCalendar.PreviousButton` | A week back. Dead at the bounds |
| `AgendaCalendar.NextButton` | A week forward |
| `AgendaCalendar.TodayButton` | Back to this week. It does not choose today |
| `AgendaCalendar.Weekdays` | The seven column headings |
| `AgendaCalendar.Week` | The seven days |
| `AgendaCalendar.Day` | One day, and the mark under it |
| `AgendaCalendar.MonthPicker` | The twelve months, in a row where the days were |
| `AgendaCalendar.YearPicker` | The years, in a row where the days were |
**Unlike the `Calendar`, this one draws its own card**: a `surface` ground, a corner and its
padding. A strip is a thing you put on a screen; a month grid is a thing you put in a sheet.
## It is a `Calendar` folded down to a week
**And the row of marks is the whole difference.** A strip of seven numbers is a date picker.
A strip of seven numbers with marks under some of them is an agenda.
```tsx
```
`events` is a list rather than a predicate, because a list is what a caller has: a month of
events mapped to their dates. It is read **by day**, so the time each one carries is ignored,
and it is turned into a set once per change rather than scanned per cell.
**The cells are the `Calendar`'s own style**, resolved through `calendarRecipe` rather than a
second table. A strip and a month showing two different discs for the same chosen day is
what that sharing exists to prevent — and the two sit one above the other the moment a caller
expands one into the other, which is what the chevron beside the title is usually for.
## Why it is a component rather than a prop
**It steps by weeks.** That is a different unit from the `Calendar`'s month, so the state
under it is different too: a `layout="week"` prop would have had to mean "and now `month` is
a week", which is a prop that changes what another prop means.
Everything genuinely shared — the cell, the disc, the mark, the column headings, the date
arithmetic — is shared. The API is not, because the two do not do the same thing.
## No day is ever "outside"
All seven are on screen and all seven are choosable. A strip that greyed out the two days
belonging to next month would be greying out days it is showing.
Only `minValue` / `maxValue` make a day inert. The chevrons go dead when the week they would
reach has no selectable day in it — the `Calendar`'s rule, one unit down.
## Swipe to page the week
A horizontal drag on `AgendaCalendar.Week` turns the week — **left for the next, right for
the previous** — so the chevrons are a shortcut rather than the only way. The strip follows
the finger a little and springs back; past ~48pt, or a fast flick, the week changes. A drag
that would land on a week with no selectable day does nothing, exactly like the dead chevron.
A tap on a day never starts a drag, and a vertical drag is handed straight back to whatever
scrolls the page. `swipeable={false}` turns the gesture off — and with it the
`react-native-gesture-handler` requirement.
## The week and the day are two pieces of state
```tsx
```
Paging weeks is not choosing a day, for the reason `calendar.md` gives about months.
## Today, in one press
`AgendaCalendar.TodayButton` brings today's week on screen, closes any month or year row
that is open, **and chooses today** — the same three things legacy's dialog did. A button
that dropped you on the right week and left you to find the day would be half a button.
`select` still honours the bounds, so an out-of-range today moves the strip without choosing.
It goes dead, and reads dead, only when there is nothing left for it to do: today's week is
showing, the day strip is the view, and today is the chosen day. Page away, open a picker,
or choose another day and it comes back to life.
The word is the caller's — "Today", "Aujourd'hui", "Hoy" — and R3 wraps a text child into
the label.
## Changing the month and the year
```tsx
const [view, setView] = useState('week')
setView(v => (v === 'week' ? 'year' : 'week'))}>
Today
{view === 'year' ? (
) : view === 'month' ? (
) : (
<>
>
)}
```
`view` is a third piece of state, controlled the way `value` and `week` are. The pickers are
**rows in the strip's place**, not a grid over it — they scroll sideways where the days
were, the way the strip itself does. The walk is the `Calendar`'s, one unit down: the title
opens the **years**, a year opens that year's months, a month lands back on the week that
holds it.
**One pair of chevrons pages all three.** `PreviousButton` / `NextButton` step whatever
`view` is showing — a week, a month, or a year — and go dead when the unit they would reach
has no selectable day in it. The caller reads `view` to render one of the three; the pickers
only ever step it.
## The title names the middle day's month
A week can straddle two. The **fourth of seven** is always in the majority month, and it is
the only rule that does not call a week with six September days in it "August".
## Sizes
| `size` | Cell | Nav button | Pill |
| ------ | ---- | ---------- | ----- |
| `sm` | 36 | 32 | 12/16 |
| `md` | 40 | 36 | 14/20 |
| `lg` | 44 | 40 | 16/24 |
The cell is the `Calendar`'s at the same `size`, so a strip and a month at `md` line up.
## Variants and colour
The `Calendar`'s four, on the same tokens, because the chosen day is the same day. **The
Today pill answers the same `variant`**: the variant is the card's one level of emphasis, and
a strip whose chosen day is a soft wash under a pill that kept a hard accent border is two
variants on one component.
| `variant` | Chosen day | Today pill |
| ----------- | ---------------- | -------------------------------- |
| `primary` | Accent disc | Bordered, the word in the accent |
| `secondary` | Soft accent disc | Soft accent fill |
| `tertiary` | Neutral disc | Neutral fill |
| `ghost` | Inverted disc | The word alone |
The emphasis runs the other way round from a `Button`'s, and on purpose: `primary` outlines
rather than fills, because the pill sits between two bare chevrons and a filled accent there
would read as the primary action of the whole card. The accent goes on the word instead.
`color` is a raw value (R7) and reaches the disc, the mark on it, **and whatever the variant
gave the pill** — the border and the word on `primary`, the fill on `secondary` and
`tertiary`, the word on `ghost`.
## Cells of your own
`AgendaCalendar.Week` takes a function, exactly as `Calendar.Grid` does and for the same
reason: seven cells are generated from a date rather than written.
```tsx
{date => (
{date.getDate()}
)}
```
## Props
### `AgendaCalendar`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ---------------- | --------------------------------- | ------------ | ---------------------------- |
| `variant` | `CalendarVariant` | `'primary'` | The chosen day and the pill |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The cell and the controls |
| `radius` | `RadiusKey` | `'2xl'` | The card's corner |
| `color` | `string` | — | The disc, its mark, the pill |
| `value` | `Date` | — | Controlled |
| `defaultValue` | `Date` | — | |
| `onValueChange` | `(value: Date) => void` | — | Midnight, local time |
| `week` | `Date` | — | Any day in the visible week |
| `defaultWeek` | `Date` | the value's | |
| `onWeekChange` | `(week: Date) => void` | — | |
| `view` | `AgendaCalendarView` | — | Controlled strip on screen |
| `defaultView` | `AgendaCalendarView` | `'week'` | Uncontrolled starting strip |
| `onViewChange` | `(v: AgendaCalendarView) => void` | — | Fires when a picker steps it |
| `events` | `Date[]` | — | The days that carry a mark |
| `minValue` | `Date` | — | Compared by day |
| `maxValue` | `Date` | — | |
| `firstDayOfWeek` | `0`–`6` | the locale's | |
| `locale` | `string` | the device's | |
| `isDisabled` | `boolean` | `false` | |
| `asChild` | `boolean` | `false` | |
### `AgendaCalendar.Week`
`children` (a node or a `(date) => node` function), plus `swipeable` (`@default true`) —
the horizontal drag that pages the week — and everything `View` takes with the `ViewStyle`
keys as props (R14).
### `AgendaCalendar.Day`
`date` (required), `children`, `isDisabled`, plus everything `PressableFeedback` takes and
the `ViewStyle` keys as props (R14).
### `AgendaCalendar.MonthPicker`
Everything `ScrollView` accepts, plus `format` — `'long'` (`"septembre"`) or `'short'`
(`"sept."`, the default), clipped to a line since the row scrolls sideways.
### `AgendaCalendar.YearPicker`
Everything `ScrollView` accepts, plus `firstYear` / `lastYear` — the ends of the row,
defaulting to fifty each way, or to the bounds' own years when the strip is bounded.
## Extending it
`useAgendaCalendar()` is exported (R10) and carries the seven days, the chosen one,
`hasEvent`, `view` / `setView`, and the moves — `goByWeeks`, `goByMonths`, `goByYears`,
`page` (whichever the view shows), `goToYear`, `goToMonthInYear`, `goToToday` and `select` —
plus `isOnToday` and `isTodayResolved`.
Enough to write a strip of your own: names under the numbers, a count instead of a dot, a
second mark in another colour. Outside an `` it throws by name.
## Accessibility
- Each day is a `button` labelled with its full date, and a marked day says so in its label:
a dot is invisible to a screen reader otherwise. The word is one language, which is the
honest limit — a caller with two passes `accessibilityLabel`, and that wins.
- The weekday row is hidden from the accessibility tree; each day announces its own date.
- The chevrons need an `accessibilityLabel` from the caller, for the `Calendar`'s reason.
# Alert
A message the interface has to make sure is read — an outcome, a warning, a failure.
## Import
```tsx
import { Alert } from '@xaui/native/alert'
```
## Anatomy
```tsx
```
- **`Alert`** — the root. A `View` with `accessibilityRole="alert"`, laid out as a row of
three columns. It resolves the recipe once and publishes the resolved styles to its
slots. A string child is wrapped in an `Alert.Description` automatically.
- **`Alert.Icon`** — the mark of what the message is. It takes the alert's size and colour
without being told either. **It renders a box**, unlike `Button.Icon` and `Chip.Icon`:
an alert's icon sits beside a block of text, and lining the glyph up with the first
line's cap-height means offsetting it by half the leading — which needs a node.
- **`Alert.Content`** — the middle column, and the one that takes the width the other two
leave. It exists because the root is a row: without it the title and the description
would be laid out _beside_ the icon rather than under each other.
- **`Alert.Title`** — the heading. It wraps; `numberOfLines` is a prop for a title that
has to fit a row.
- **`Alert.Description`** — the message. It sits behind the title on a fraction of the
title's own colour.
- **`Alert.Close`** — the dismiss affordance, and **the only control an alert contains**.
**No slot carries a margin** (R4). What separates the three columns is the root's `gap`,
and what separates the title from its description is the content's own — so JSX order is
screen order and a leading cross is a matter of where you wrote the slot.
**The alert is never a control.** There is no `isPressable` and no press behaviour on the
type: an alert reports, and what you press is its `Alert.Close`.
## Usage
### Basic
```tsx
Le fichier dépasse la taille autorisée.
Facture payée
Le reçu part par courriel dans un instant.
```
### Dismissible
```tsx
Connexion perdue
Les modifications sont enregistrées localement.
```
The alert does not hide itself. Mounting is the caller's, which is why there is no
`isVisible`, no `onVisibleChange` and no exit animation baked in — a component that
unmounts itself cannot be animated out by whatever holds it.
### An action inside the message
```tsx
Mise à jour disponible
Une nouvelle version est prête.
Redémarrer
```
### Single line
```tsx
Profil mis à jour
```
The root aligns its columns to the top, because an alert's message can run to three lines
and an icon floating beside the middle of a paragraph reads as decoration. An alert with
no description centres itself with one style prop, and drops the icon's optical offset
with another.
### An icon that disagrees with its alert
```tsx
Profil enregistré
```
This is the reference implementation's alert written out: a neutral surface with the status carried by the icon
alone. There it is what `status` does; here the variant decides both the surface and the
foreground, so an icon that has to disagree says so with a raw `color` (R7).
### As another element
```tsx
…
```
The caller's element **is** the alert, so the auto-wrap does not apply — write the slots
yourself inside it.
### Sizes
`size` drives the padding, the gaps, the radius and the type — **never a height**. An
alert is a surface: it is as tall as the message it carries.
| `size` | Padding | Gap | Title | Description | Icon | Icon offset |
| ------ | ------- | --- | ----- | ----------- | ---- | ----------- |
| `xs` | 8 | 8 | 12/16 | 12/16 | 14 | 1 |
| `sm` | 10 | 10 | 14/20 | 12/16 | 16 | 2 |
| `md` | 12 | 12 | 16/24 | 14/20 | 18 | 3 |
| `lg` | 16 | 14 | 18/28 | 16/24 | 20 | 4 |
`md` is the reference implementation's alert measured: 12pt of padding, a 12pt gap, a 24pt radius, a 16/24 title
above a 14/20 description, an 18pt icon. The icon's offset is **half the title's leading**
rather than their hard-coded 3.5px — it lands on the same 3 at `md`, and stays right at
the other three sizes.
### Variants
Nine names. An alert is the one component that is **both** a surface and a report, so it
takes the `Card`'s vocabulary for its neutral level and the `Chip`'s for the rest.
| `variant` | Background | Text | Shadow |
| -------------- | ------------- | ----------------------- | ------ |
| `default` | `surface` | `surfaceForeground` | yes |
| `primary` | `accent` | `accentForeground` | — |
| `secondary` | `accentSoft` | `accentSoftForeground` | — |
| `success` | `success` | `successForeground` | — |
| `success-soft` | `successSoft` | `successSoftForeground` | — |
| `warning` | `warning` | `warningForeground` | — |
| `warning-soft` | `warningSoft` | `warningSoftForeground` | — |
| `danger` | `danger` | `dangerForeground` | — |
| `danger-soft` | `dangerSoft` | `dangerSoftForeground` | — |
`default` is the `Card`'s `default`, token for token — the surface fill and the surface
shadow, which is the reference implementation's alert root exactly. The elevation belongs to that one variant: a
tinted alert already separates itself by its fill, and a shadow under it would read as dirt.
`tertiary` and `ghost` are absent: an alert without a surface is a paragraph. The outlined
alert is `default` with `borderWidth` and `borderColor` as style props.
**The icon takes the title's colour**, not a status colour of its own — on a `danger` alert
the readable colour is the one the title already uses, and on a `danger-soft` one that token
_is_ the red.
### Colour
```tsx
Sprint 12 ouvert
```
A raw tint, never a token (R7). It paints the fill, and its OKLab-contrasted slice paints
the title, the icon and the cross — so a brand-coloured alert stays readable without being
told a second colour. It resolves outside the style cache, which is why it must be a hex
value.
### Style as props
Every node takes its own style keys as props (R14) — full React Native names, full React
Native values, no hidden scale:
```tsx
…
```
### Everything else goes through `style`
- **A gradient fill, a tinted shadow, a coloured left edge** — the variant names one token
for the surface, and a second one is not a variant.
- **`overflow: 'hidden'`** is not set on the root, deliberately: on iOS it clips the node's
own shadow, and a `default` alert would lose the elevation its variant just gave it.
## Props
### `Alert`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------- |
| `variant` | `AlertVariant` | `'default'` | The nine names above |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Padding, gaps, radius, type. Never a height |
| `radius` | `RadiusKey` | — | Overrides the radius `size` implies |
| `color` | `string` | — | A hex tint, placed by the variant |
| `isDisabled` | `boolean` | `false` | Dims, and reaches the close inside |
| `asChild` | `boolean` | `false` | Merge into the single child instead of rendering |
| `accessibilityRole` | `AccessibilityRole` | `'alert'` | Overridable |
| `accessibilityLiveRegion` | `'none' \| 'polite' \| 'assertive'` | `'polite'` | Android announces it on change. `'none'` for a static alert |
### `Alert.Icon`
The three forms of `Icon` — `as`, a raw SVG child, `source` — plus the `ViewStyle` keys of
its box as props (R14). `size` and `color` default to what the root resolved.
**No default glyph**, where the reference implementation ships three. XAUI publishes no icon set — `@xaui/icons`
was deleted in P0 — so the icon is always yours, and the alert's job is to size and colour
it rather than to choose it.
### `Alert.Content`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14). It takes the width the
icon and the close leave.
### `Alert.Title`, `Alert.Description`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14). Both wrap.
### `Alert.Close`
Everything the shared `CloseButtonBase` accepts — `accessibilityRole` is `'button'` and
`hitSlop` is `8` by default, and `isDisabled` falls back to the alert's. With no children it
draws its own cross, so a dismissible alert works in a project that has installed no icon
set.
## Accessibility
- The root is `accessibilityRole="alert"` and `accessibilityLiveRegion="polite"` by
default: on Android an alert that _appears_ announces itself instead of waiting to be
found, and queues behind whatever is being read rather than cutting it off. An alert that
is part of the page rather than a response to something sets `'none'`.
- **`Alert.Close` needs an `accessibilityLabel`**, and warns in development without one. A
cross is not text, and the title beside it names the message, not the action.
- **The icon is decoration.** An alert whose meaning is carried only by its colour or its
glyph is unreadable to a screen reader and to anyone who does not distinguish the hue —
put it in the title.
- `Title` and `Description` are real `Text`, so they are read in order and they reflow at
large font sizes rather than truncating.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| --------------------------------- | ------------------------------------------------------------- |
| `title="…"` | `… ` |
| `description="…"` | `… `, or a string child |
| `icon={ }` | ` ` — sized and coloured by the alert |
| `hideIcon` | removed — do not write the slot |
| `themeColor="success"` | `variant="success-soft"` (or `"success"` for the solid fill) |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `variant="flat"` / `"faded"` | the `-soft` slice |
| `variant="solid"` | the full slice: `variant="danger"` |
| `variant="bordered"` | `variant="default"` + `borderWidth` / `borderColor` |
| `variant="glass"` | removed — a theme, not an alert prop. Out of 1.0 |
| `radius={8}` | `radius="sm"`, or `borderRadius={8}` as a style prop |
| `isClosable` / `closeButton` | ` ` — composed, so you place it |
| `isVisible` / `onVisibleChange` | removed — the caller mounts and unmounts the alert |
| `onClose` | `onPress` on `Alert.Close` |
| `style` | `style` on the root |
| `titleStyle` / `descriptionStyle` | `style` on `Alert.Title` / `.Description` |
| `children` | anything inside `Alert.Content`, after the description |
# AreaChart
The [`LineChart`](../line-chart/line-chart.md) with the ground under it filled.
```tsx
import { AreaChart } from '@xaui/native/area-chart'
```
Everything shared is in [`chart.md`](../chart/chart.md).
## Stacked and overlaid answer different questions
**Stacked** reads as a total split into parts. **Overlaid** reads as several quantities
compared. A stacked chart whose parts are not parts of one whole is a chart that adds
unrelated numbers together — which is why it is a prop a caller sets rather than what happens
by default the moment there are two series.
The stack is summed into the data before it is scaled, so the plot's own domain reaches the
top of it: a stack whose top leaves the axis is a stack drawn outside its own chart. The rows
are copied, never written to, and the totalled column is prefixed so it cannot collide with
one of the caller's.
Series are painted back to front, so the shortest is not hidden by the tallest.
## The fill is a gradient, and a light one
The ink belongs at the line, where the number is; the further from it, the less there is to
say. So the fill runs from **under a fifth** of the colour at the line to nothing at the axis.
Kept that light on purpose. The line carries the number and the fill only says which side of
it is "under" — anything heavier competes with the line, and two overlaid series stop being
two, because the lower one reads as a shadow of the upper rather than as its own quantity.
## Its own props
| Prop | Type | Default | Notes |
| ------------- | ------------------------ | ------------ | ------------------------ |
| `curve` | `'monotone' \| 'linear'` | `'monotone'` | |
| `isStacked` | `boolean` | `false` | Total split into parts |
| `strokeWidth` | `number` | `2` | `0` leaves the fill bare |
# Autocomplete
A field that opens a list you search.
## Import
```tsx
import { Autocomplete } from '@xaui/native/autocomplete'
```
## Usage
```tsx
État
Californie
Texas
Aucun résultat
Cinquante États, une seule ligne.
```
## Anatomy
| slot | what it is |
| -------------------------- | ------------------------------------------ |
| `Autocomplete` | The column, and the resolved style |
| `Autocomplete.Label` | What the field is for |
| `Autocomplete.Trigger` | The control — a `combobox` |
| `Autocomplete.Value` | The chosen row's label, or the placeholder |
| `Autocomplete.Indicator` | The chevron, turning with the panel |
| `Autocomplete.Overlay` | The backdrop. Optional |
| `Autocomplete.Content` | The panel |
| `Autocomplete.Search` | The field you type in |
| `Autocomplete.Item` | One result |
| `Autocomplete.Empty` | What the panel says when nothing matches |
| `Autocomplete.Description` | The hint under the field |
| `Autocomplete.Error` | What is wrong with the choice |
## The root is the column, not the field
A `View` that stacks `Autocomplete.Label`, the trigger and `Autocomplete.Description` /
`.Error` with one `gap`, so JSX order is screen order. It is the column and the help lines
that make this a field on a form rather than a button that opens a list — and they are the
`TextField`'s, token for token, so a text field and an autocomplete on the same form read as
one control. The `DatePicker` borrows the same three slots for the same reason.
`isInvalid` paints the trigger's border and turns the label and the description `danger`; it
never mounts or unmounts `Autocomplete.Error`. A slot that silently renders nothing is one
you cannot debug, so you write the condition yourself:
```tsx
État
…{error ? {error} : null}
```
`Autocomplete.Trigger` stays the field: it keeps its own `ref` — it is the node the panel
measures — and its own a11y props. `Autocomplete.Overlay` and `Autocomplete.Content` portal
out, so they add nothing to the column.
## It is not a `Select`
A select is for a list you **read**: a dozen options, all of them visible, and choosing is
recognising one. An autocomplete is for a list you **cannot** read — fifty states, four
thousand cities — where choosing is _finding_, and the field you type in is the control
rather than an extra row in a menu.
Same trigger, same panel, same rows; a different thing to do with them. So the two share
their style **by construction**: the trigger, the panel and the rows resolve through
`selectRecipe`, and only the search box and the empty line are this component's own. A
second table would be two to keep in step, and the drift would show as a select and an
autocomplete side by side in a form with fields half a shade apart.
The rows are plainer than a select's for the same reason. No description, no check: a check
beside the row already chosen is of no use in a list you reached by searching for something
else.
## The search is the control
`Autocomplete.Search` lives **inside** `Autocomplete.Content`, and the panel pins it above
its scroller so it stays put while the results move under it. It takes focus as the panel
opens — an autocomplete you have to tap twice before you can type into it is a select with
a spare row — and `autoFocus={false}` turns that off for a panel short enough that the
keyboard would cover it.
**The query goes with the panel.** Closing clears it, because a search that survived its own
closing would mean the list is already filtered the next time it opens, by a word nobody can
see. `query` / `defaultQuery` / `onQueryChange` if you would rather own it.
### What matching means
`geneve` finds **Genève**, and `genève` finds **Geneve** — diacritics are folded and case is
dropped, both ways.
`york` finds **New York**: it matches any word rather than the first, because a long list is
searched by whichever word someone remembers, not by the one that happens to come first.
An empty query keeps everything, which is what makes the field's resting state the whole
list rather than none of it.
## Filtering is by row, and only the rows it can see
`Autocomplete.Content` drops the rows that do not survive the query. It reads them the way
`Autocomplete.Value` reads the chosen label — off the **elements**, before any of them
mounts — but only its **direct** children.
Walking deeper to _read_ a label changes nothing; dropping a row nested inside a component
of your own would mean rebuilding that component's children for it, and a filter that
silently rewrote your tree is worse than one that leaves it alone. A row wrapped in
something of yours is a row the search will not hide.
A row with no readable label is never hidden either — hiding it would make a custom row
disappear the moment anyone typed.
## `Autocomplete.Empty` renders instead of the results
And only when nothing matched. A panel that filtered its last row away and showed an empty
box reads as a control that has broken rather than as a search that found nothing.
## Props
| prop | type | default | description |
| --------------- | --------------------------------------------------- | --------- | --------------------------- |
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `primary` | The field's four levels |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | The control's scale |
| `radius` | `RadiusKey` | — | Overrides the corner |
| `color` | `string` | — | The tint (R7) — a raw value |
| `value` | `string` | — | Controlled selection |
| `defaultValue` | `string` | — | Uncontrolled |
| `onValueChange` | `(value: string) => void` | — | Fires on every choice |
| `isOpen` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and close |
| `query` | `string` | — | Controlled query |
| `defaultQuery` | `string` | `''` | Uncontrolled |
| `onQueryChange` | `(query: string) => void` | — | Fires on every keystroke |
| `isDisabled` | `boolean` | `false` | Stops the control |
| `isInvalid` | `boolean` | `false` | The field's error state |
`size` is the **control's** scale and the panel is not the control: a `lg` trigger opening
`lg` rows is a menu that fills the screen. Only the type in the search box follows, so what
you type reads at the size of what you will pick.
`ref`, `style` and R14's style props on the root dress the **column**. The control is
`Autocomplete.Trigger`, which takes its own — including `testID` and the a11y props, since
it is the node a screen reader stops on.
## Accessibility
The trigger is a **`combobox`** rather than a button — the control opens a list you type
into, and that is the role that says so. It carries `expanded` and, when `isInvalid`,
`aria-invalid`. The search field is a `search`. Both stay overridable (R9).
The trigger points at `Autocomplete.Label` and `Autocomplete.Description` through
`aria-labelledby` and `aria-describedby`, which is what makes a screen reader announce "État,
liste déroulante" rather than reading the chosen row and leaving the question unsaid. The
ids come from the root, so mounting the two slots is all it takes; your own
`aria-labelledby` on the trigger still wins.
The overlay announces nothing: it is the absence of the panel, and a screen reader saying
"button" over the whole screen is worse than saying nothing.
# Avatar
A person or a thing, in a circle.
## Import
```tsx
import { Avatar } from '@xaui/native/avatar'
```
## Anatomy
```tsx
```
- **`Avatar`** — the frame. It resolves the recipe once (R5) and publishes it to its slots.
- **`Avatar.Image`** — the photo. Absolutely positioned over the fallback.
- **`Avatar.Fallback`** — what shows when there is no photo, and what shows behind one that
is still loading.
- **`Avatar.Initials`** — the letters. The fallback inserts one around a stringifiable
child, so it is rarely written by hand.
## Usage
The whole component, most of the time:
```tsx
AT
```
With a photo, and initials underneath it:
```tsx
AT
```
A glyph instead of letters — and it needs no props:
```tsx
```
A logo, squared:
```tsx
```
## Props
### `Avatar`
| Prop | Type | Default |
| --------- | ------------------------------ | ----------- |
| `variant` | `AvatarVariant` — eleven names | `'default'` |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` |
| `radius` | `RadiusKey` | `'full'` |
| `color` | `string` — a raw hex tint (R7) | — |
| `asChild` | `boolean` | `false` |
| `style` | `StyleProp` | — |
### `Avatar.Image`
Every `Image` prop, plus `animation?: boolean` (default `true`) and the `ImageStyle` keys
as props (R14).
### `Avatar.Fallback` · `Avatar.Initials`
Every `View` / `Text` prop, plus that node's style keys as props (R14).
### `variant` colours the frame
The `Chip`'s eleven names, meaning here what they mean there — an avatar is a token _about_
a person or a thing, which is the category the `Chip` established. The three status families
are here because an avatar reports as often as it identifies: a red frame for the account
that failed to sync, a green one for the person who is online.
It is the reference implementation's `variant × color` matrix said once. Their `default` is this `default`, and
their `soft` crossed with five colours is `secondary` plus the five `-soft` names.
### `size` sets both sides
Thirty-two, forty, forty-eight and sixty-four — the reference implementation's three steps plus the `xs` our
ladder adds below them, which is the size an avatar is inside a `Chip` or a list row. An
avatar is a square before it is a circle, so there is one measurement rather than a width
and a height that can drift apart.
The glyph inside the fallback runs ahead of the initials at the top of the scale — 12, 14,
16, 20 against 12, 12, 14, 16 — because two letters fill a circle that one person-icon has
to sit inside with air around it.
### `radius` — a circle at every size
Where the reference implementation fixes one large radius for all three sizes, which makes their small avatars
round and their large ones squircles. `full` says the shape the name means once, and
`radius` is still there for the logo that wants a square.
## Notes
### The fallback is not a state, it is the layer underneath
`Avatar.Image` is absolutely positioned over `Avatar.Fallback`, and an `Image` with nothing
decoded yet draws nothing. So the initials show while the photo loads and **stay if the URL
is wrong** — with no load-state machine, no `onError` to remember, and nothing to get out of
sync. The reference implementation runs a status enum for this; a stacking order says the same thing and cannot
disagree with itself.
JSX order between the two is therefore free. Write the image first, as the anatomy reads.
### No default glyph
XAUI publishes no icon set — `@xaui/icons` was deleted in P0 — so the mark is always the
caller's. What `Avatar.Fallback` does instead is publish the frame's resolved size and
colour to `IconContext`, so an `Icon` written inside it needs no props at all.
### The fade
`Avatar.Image` fades in over 200ms on `onLoad` — the reference implementation's timing. It runs off a shared value
rather than off a mount animation, because the node has to be mounted from the first render
or it never fetches: the moment worth animating is the decode, not the mount.
`animation={false}` skips it and mounts no worklet.
## Accessibility
No default `accessibilityRole`: a face is an image, and what a screen reader should read is
the name beside it — announcing "image" on every row of a list is noise. Give the avatar an
`accessibilityLabel` when it really is the only label there is.
`Avatar.Initials` is hidden from the reader for the same reason: two letters standing in for
a photo are decoration, and "A T" on every row is noise. Both stay overridable (R9).
## Migration
| Legacy | v1 |
| ---------------------------------------- | -------------------------------------------------- |
| ` ` | ` ` |
| ` ` | `AT ` |
| ` ` | ` ` |
| ` ` | ` ` |
| ` ` | ` ` or its style props |
# Badge
A count, or the fact that there is one.
## Import
```tsx
import { Badge } from '@xaui/native/badge'
```
## Anatomy
```tsx
```
**One node and no slots**, per the plan. A badge is a mark: whatever is inside it is one
line of two or three characters, and a slot would be a name for a `Text` this component can
just as well insert itself (R3).
## Usage
```tsx
3
Payé
```
Hanging off what it counts:
```tsx
12
```
At the end of a list row, in flow, where no placement is wanted:
```tsx
Boîte de réception
128
```
## Props
| Prop | Type | Default |
| ----------- | ------------------------------------------------------------ | ---------- |
| `variant` | `BadgeVariant` — the `Chip`'s eleven names | `'danger'` |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` |
| `radius` | `RadiusKey` | capsule |
| `color` | `string` — a raw hex tint (R7) | — |
| `isDot` | `boolean` | `false` |
| `placement` | `'top-end' \| 'top-start' \| 'bottom-end' \| 'bottom-start'` | — |
| `asChild` | `boolean` | `false` |
| `style` | `StyleProp` | — |
Plus every `ViewStyle` key as a prop (R14) and every `View` prop.
### `danger` is the default
The only component in the library whose default is not the first name in its ladder. A badge
is overwhelmingly the count of something that wants attention — unread, failed, overdue —
and a red one is what a caller writing `3 ` means. The accent count is a
`variant` away.
### It is not a small `Chip`
A chip holds a word and hugs it; a badge holds a count and is round unless the count is too
wide to be. That is the `minWidth` equal to the height — one digit is a circle, two are a
capsule — and it is why the label stays at 12pt through three of the four sizes: a count
that grows with its badge stops being a count.
The four heights sit below the `Chip`'s: 16, 18, 20 and 24 against its 20, 24, 28 and 36.
### `isDot` — the bare circle
No label, no padding, a fixed diameter on its own ladder (6, 8, 10, 12) rather than the
height, because a 20pt circle beside a 16pt icon is not a dot. It is what a badge is when
the fact that there is _something_ is the whole message.
Children are not rendered while it is set: a dot is the absence of a label.
### `placement` — the parent becomes whatever the badge decorates
Unset, the badge is in flow and the parent lays it out like any other node, which is what a
badge at the end of a list row wants. Set, it is absolutely positioned in that corner,
pulled out by half its own height on each axis so its centre lands on the corner it marks.
It is a prop rather than four style keys at the call site because the offset is derived from
the badge's own `size`, and that arithmetic belongs with the measurements. R13 is the other
half: the keys are `start` and `end`, so a badge on the trailing corner mirrors in RTL
instead of staying put.
The insets are computed **outside the style cache**, and they have to be: in flow the node
is `position: 'relative'`, where an inset is a nudge rather than a placement, so a cached
style carrying `top: -10` would shift every badge that has no placement at all.
## Accessibility
No default `accessibilityRole`: a badge is a label, and what a screen reader reads is the
text inside it. What it cannot read is the **subject** — "3" beside a bell means three
notifications only to someone who can see the bell — so a placed badge is one of the few
places in this library where an `accessibilityLabel` is not optional.
## Migration
| Legacy | v1 |
| ------------------------------------ | ------------------------------------------- |
| ` ` | `3 ` |
| ` ` | ` ` |
| ` ` | ` ` |
| ` ` | ` ` (RTL-safe) |
| `{children} ` wrapping | the parent wraps; the badge is placed on it |
# BarChart
A bar per row, and a bar per series inside it once there is more than one.
```tsx
import { BarChart } from '@xaui/native/bar-chart'
```
Everything shared is in [`chart.md`](../chart/chart.md).
## Grouped compares, stacked totals
Stacked is the default, because a bar chart with two series is usually one quantity split.
The [`AreaChart`](../area-chart/area-chart.md) makes the same distinction in the same words.
Grouped divides the slot the row already has, rather than introducing a second scale: the
group **is** the slot, and each series takes an equal share of it.
Only the topmost segment of a stack is rounded. A corner between two segments is a gap in a
bar that is meant to read as one.
## The corner is a capsule, and it is computed
`radius` is a **raw number in points**, not a `RadiusKey`: a bar's corner is bounded by its
own width, not by the container scale, and a `2xl` on a six-point bar would be a circle.
Unset, it is **under a third of the bar's width**, computed from the slot the scale gave it
so it holds at four bars and at forty.
A third rather than a half, and the half is what a capsule is: a capsule reads as a shape
rather than as a measurement — the eye stops at the dome instead of at the value it marks —
and a narrow bar, one of three in a group, becomes a pill with no flat top at all.
`barPath` clamps it to the bar's own **height** as well, which is what keeps the shortest bar
a stadium rather than a shape whose two arcs cross and render as a knot. That case is not
exotic: it is the first bar of every chart that starts near zero.
## Its own props
| Prop | Type | Default | Notes |
| ----------- | --------- | ------------ | ------------------------------------ |
| `isGrouped` | `boolean` | `false` | Side by side rather than stacked |
| `radius` | `number` | half the bar | Points, clamped to the bar's own box |
| `gap` | `number` | `0.4` | The share of a slot left empty |
# BottomSheet
A surface that comes up from the bottom edge and can be thrown back down.
## Import
```tsx
import { BottomSheet } from '@xaui/native/bottom-sheet'
```
`react-native-gesture-handler` is an **optional** peer of this package, and this component
needs it. It is imported here and in the `Slider`, so an app that uses neither never pays
for it.
## Anatomy
```tsx
…
…
…
…
```
- **`BottomSheet`** — state and resolved style. **It renders no node.**
- **`BottomSheet.Trigger`** — what brings it up.
- **`BottomSheet.Overlay`** — the backdrop. It dims, and closes on a press.
- **`BottomSheet.Content`** — the sheet, and the gesture.
- **`BottomSheet.Handle`** — the grab bar.
- **`BottomSheet.Title`** / **`BottomSheet.Description`** — its two texts.
- **`BottomSheet.Close`** — anything that sends it back down.
## Usage
### Basic
```tsx
Partager
Partager ce document
```
### One that cannot be escaped
```tsx
…
```
**Two separate refusals**, because a sheet that can be tapped away but not dragged is a real
design, and so is the reverse.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `BottomSheet`
| prop | type | default | description |
| ------------------ | --------------------------- | ------- | --------------------------------------------- |
| `radius` | `RadiusKey` | — | Overrides the **top** corners |
| `isOpen` | `boolean` | — | Controlled |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close |
| `isDisabled` | `boolean` | `false` | Stops the trigger |
| `dismissThreshold` | `number` | `0.35` | How far down, as a fraction of its own height |
`radius` moves the **top** corners only. A sheet sits on the bottom edge of the screen, so
its lower corners are off it — rounding them would put two arcs against a straight edge
nobody can see.
No `variant`: a sheet is the theme's floating surface, with no emphasis to report.
### `BottomSheet.Content`
`isSwipeable` (default `true`), plus `ViewProps` and `ViewStyle` as props.
### `BottomSheet.Handle`
Written by you, not drawn by the content — a sheet with `isSwipeable={false}` should not
advertise a gesture it refuses. It is the **only** thing telling a reader the sheet can be
dragged, so leave it out only when the drag is off.
## How the gesture works
**It measures its own height, then slides that far.** A sheet is as tall as what is in it,
and nothing else on the screen knows that number — the first layout is what tells the
animation how far "down" is. Until it has one the sheet waits off-screen at a pessimistic
distance rather than flashing at its resting place for a frame.
**Downward only.** A sheet dragged upward has nowhere to go — it is already against the top
of its own content — and letting it stretch there is a rubber-band nobody asked for.
**Far enough or fast enough.** Past `dismissThreshold` of its height it closes; so does a
flick over 900 points a second, whatever the distance. Without the second, a quick flick
from the top of a tall sheet is refused however clearly it meant to throw the thing away.
Anything short of either springs back.
## A reduced state
```tsx
…
```
`collapsedHeight` gives the sheet a **second disclosure inside the first**: it is either up
or gone, and while it is up it is either full or reduced. `isExpanded`, `defaultExpanded`
and `onExpandedChange` control it the way `isOpen` controls the other one.
**These are not snap points.** There are two heights, not an array of them, and the reduced
one is a number you give rather than a fraction of the screen the sheet works out.
**The sheet is not re-laid out.** It is the same box at its full height, moved further down,
so the tail below the reduced height slides off the bottom of the screen and comes back
untouched. Nothing re-measures — which is why a bare `collapsedHeight` cuts wherever the
number happens to land, and why `BottomSheet.Summary` below exists.
### `BottomSheet.Summary` — cut at a seam you chose
```tsx
Café des Arts
★★★★☆ · Ouvert jusqu'à 22 h
```
It is `` to the sheet's ``, and the same thing an `Accordion.Trigger` is:
**the part that survives**, not a second view for the reduced state. It renders in both —
what changes is whether everything under it does — so it costs no extra layout.
**It reports where its bottom edge falls**, not how tall it is, so whatever sits above it is
counted too: a handle above a summary stays visible when the sheet reduces. That is why it
must be a **direct child of `Content`** — `y` is relative to the immediate parent, and a
summary wrapped in a `View` would report the wrapper's coordinates.
**The sheet adds its own bottom padding back onto that edge.** Cutting on the summary's last
pixel would leave the reduced sheet with air above the handle and none at all under the last
line — the text against the screen edge, and under the gesture bar on a phone that has one.
The reduced sheet ends with the padding its expanded self ends with, and overriding the
sheet's `padding` moves the seam with it.
`collapsedHeight` is not extended that way. It is a number you wrote against a sheet you
were looking at, and two hundred points showing has to mean two hundred.
Give both and the summary wins, with a warning in development: a measurement of the content
is truer than a number that has to be kept in step with it.
### Where a drag goes
A drag that was not decisive puts the sheet back, whatever distance it covered. Decisive is
past `dismissThreshold` of the sheet's height **or** faster than 900 points a second, either
alone being enough.
| from | decisive down | decisive up |
| --------- | ---------------------------------------------------------------- | ----------- |
| expanded | collapsed — or **closed**, if the throw was aimed past the notch | — |
| collapsed | closed | expanded |
The exception in the first row is the one thing a strict one-state-per-drag rule gets wrong:
dragging a sheet the whole way to the bottom and having it stop half open reads as a
refusal. Where the throw was aimed is the release point plus 0.15 s of its velocity.
Without a `collapsedHeight` there is no middle row and no exception — the sheet behaves
exactly as it always has.
### The handle becomes a control
On a collapsible sheet `BottomSheet.Handle` is pressable, the way an `Accordion.Trigger` is,
and a press toggles the two heights. That is not decoration acquiring a behaviour by
accident: a drag would otherwise be the only way in and out of the reduced state, and a drag
is a gesture some people cannot perform. It carries `accessibilityRole="button"` and
`accessibilityState={{ expanded }}`, and warns in development without an
`accessibilityLabel` — a pill says nothing to someone who cannot see it.
Without a `collapsedHeight` the handle stays what it was: a pill, hidden from screen
readers, taking no touches.
**The backdrop does not know about any of this.** A reduced sheet is often a persistent
panel rather than a modal, and a dimmed page behind one reads oddly — leave
`BottomSheet.Overlay` out, or drive its own props, if that is the sheet you are building.
## Not `@gorhom/bottom-sheet`
The reference implementation wraps it. A sheet that slides, springs and dismisses is a pan gesture and a shared
value; taking a dependency for that would put a second animation library in every app that
installs one component. What we lose is their scroll integration, which is worth having and
worth its own change rather than a dependency.
Their snap points we do not have and are not planning: `collapsedHeight` covers the case
they are almost always used for, with two named states instead of an array of positions.
## Accessibility
The sheet is `accessibilityViewIsModal`, so a screen reader stops at it rather than reading
the page behind — the spoken half of what the backdrop says visually. The title is a
`header`. The handle and the overlay announce nothing: a pill carries no information, and
the drag it stands for is not a gesture a screen reader performs.
# Button
A control that runs an action when it is pressed. The reference component: every other one
in the library is this shape.
## Import
```tsx
import { Button } from '@xaui/native/button'
```
## Anatomy
```tsx
```
- **`Button`** — the root. A `PressableFeedback`, which owns the press state, resolves the
recipe once and publishes the resolved styles to its slots. String children are wrapped
in a `Button.Label` automatically.
- **`Button.Label`** — the text. Single-line by default: a control has a fixed height, so a
label longer than the button truncates rather than deforming it.
- **`Button.Icon`** — an icon that takes the variant's colour and the size's scale with no
prop. Accepts `Icon`'s three forms: `as`, a raw SVG child, or `source`.
- **`Button.Spinner`** — the busy indicator. `isLoading` inserts one when none is composed.
The view depth is one — `PressableFeedback > (Text | Icon)`. There is no wrapper view.
## Usage
### Basic
Text children are wrapped for you.
```tsx
Envoyer
```
This works through a recursive stringify, not an inspection of the first child, so
`{count} items ` is a label too — its children are the array `[3, ' items']`.
### Composed
Use the slots when you need more than a label. **JSX order is screen order**: there is no
`startContent` or `endContent`, and no slot carries a margin of its own — the gap belongs to
the root.
```tsx
Supprimer
Supprimer
```
### Icon only
```tsx
```
`isIconOnly` drops the horizontal padding and squares the button on its fixed height. There
is no width to compute. **`accessibilityLabel` is required in practice** — there is no text
to announce, and the component warns in development when it is missing.
### Loading
```tsx
Envoi…
```
The spinner is inserted at the head when none is composed. Compose one to place it
yourself:
```tsx
Envoi…
```
A loading button is not pressable, and announces as busy rather than as disabled.
### As another element
`asChild` merges the button's props — the ref, the styles, the handlers — into its single
child instead of rendering a pressable of its own. The child _is_ the button.
```tsx
Voir les projets
```
Neither the auto-wrap nor the auto-spinner applies there: there is one child, and it is
yours.
### Sizes
```tsx
xs
sm
md
lg
```
`size` drives **height, padding, gap, radius and type — never width**. A button with no
width fills its parent in a column and hugs its content in a row, which is React Native's
own behaviour. There is no `fullWidth` prop — the deliberate case is `width="100%"`, said
explicitly. To tighten one, compose:
```tsx
Envoyer
Envoyer
```
### Variants
Seven values, emphasis and intention in one flat union. Each names tokens and computes
nothing.
```tsx
primary
secondary
default
tertiary
ghost
danger
danger-soft
```
| `variant` | background | border | label |
| ------------- | ------------ | -------- | ---------------------- |
| `primary` | `accent` | — | `accentForeground` |
| `secondary` | `accentSoft` | — | `accentSoftForeground` |
| `default` | `default` | — | `defaultForeground` |
| `tertiary` | transparent | `border` | `foreground` |
| `ghost` | transparent | — | `foreground` |
| `danger` | `danger` | — | `dangerForeground` |
| `danger-soft` | `dangerSoft` | — | `dangerSoftForeground` |
The ladder descends by how much accent is left: `primary` is the full accent, `secondary`
its soft slice, `default` gives up the accent for the neutral fill, `tertiary` gives up
the fill for a border, `ghost` gives up that too. **`secondary` is to `primary` what
`danger-soft` is to `danger`** — the same soft slice of the same family, which is also why
a raw `color` lands on it with no rule of its own.
**No `success` and no `warning`.** `danger` earns its place because destruction is an
action a button performs; a success is an outcome and a warning is a state, and neither is
something you press — they belong to whatever reports status (`Alert`, `Chip`, `Badge`).
The theme keeps the tokens, so a button that genuinely needs that fill passes the tint:
```tsx
Valider
```
### Colour
One raw tint, and where it lands follows the variant rather than the prop:
```tsx
// violet background
// violet label, no background
// violet border and label
```
It is a **hex value, never a token**. Its contrasted, soft and pressed slices are derived
in OKLab, so a free tint behaves exactly like `accent` — same ratios, same rendering. It is
deliberately outside the style cache: letting arbitrary values into the key would grow the
table with the colours people invent instead of with the finite combinations of tokens.
### Style as props
R14 — loosening a button, giving it a width, changing a fill does not mean opening an
object:
```tsx
Envoyer
…
Envoyer
```
**Full React Native names, and therefore full React Native values.** `padding` rather than
`p`; `padding={16}` is 16 points, exactly as `style` would be. A prop carrying the RN key's
name while silently multiplying its value by a scale would be the most expensive trap in
the API. The scale stays one word away:
```tsx
const t = useXAUITheme()
…
```
The set is not a list but a type — the node's style keys (`ViewStyle` on the root,
`TextStyle` on `Button.Label`) **minus the directional forms R13 bans**, which are not
exposed at all: `paddingStart`, never `paddingLeft`.
`Button.Icon` has them too, in `ImageStyle`, and they reach the **`source` form only** —
exactly as far as its `style` already does. The other two forms render a third-party
component or clone the caller's element, so there is no node of ours to style, and `size`
and `color` stay the escape hatch there.
Each one styles **the node it is written on**, never a descendant — which is what separates
this from the legacy `customAppearance`. `width="100%"` is what replaces `fullWidth`, said
explicitly, and `height` beats the height `size` chose, because style props resolve after
the recipe. That is an escape hatch, not the normal path: a control whose height you
straighten by hand is usually one whose size is missing from the scale.
`color` keeps its meaning here — the tint the variant places (above) on the root, and
`TextStyle`'s `color` on `Button.Label`. Those coincide rather than conflict: in a text
component there is only one thing to tint. A raw fill on the root is `backgroundColor`,
which says what it does.
They resolve outside the style cache, and the slot's own `style` still wins — it stays the
last word for `transform`, a per-platform shadow, or a computed object.
### Everything else goes through `style`
There are two appearance props, `variant` and `color`, and no third. A shadow, a border in
a different colour than the background, a gradient — those are a slot's own `style`:
```tsx
Envoyer
```
`style` also accepts `Pressable`'s function form, `(state) => style`.
## Props
### `Button`
Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts —
every `ViewStyle` key those two do not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ------------------- | ------------------------------ | ----------- | ------------------------------------------------- |
| `variant` | `ButtonVariant` | `'primary'` | The ten values above |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, padding, gap, radius, type. Never width |
| `radius` | `RadiusKey` | — | Overrides the radius `size` implies |
| `color` | `string` | — | A hex tint, placed by the variant |
| `isDisabled` | `boolean` | `false` | |
| `isLoading` | `boolean` | `false` | Not pressable; inserts a spinner if none composed |
| `isIconOnly` | `boolean` | `false` | No horizontal padding, square on the fixed height |
| `asChild` | `boolean` | `false` | Merge into the single child instead of rendering |
| `animation` | `AnimationProp` | — | `false` mounts no worklet at all |
| `accessibilityRole` | `AccessibilityRole` | `'button'` | Overridable |
### `Button.Label`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14) — `fontSize`, `color`,
`letterSpacing`… `numberOfLines` defaults to `1`.
### `Button.Icon`
`Icon`'s props, plus the `ImageStyle` keys as props (R14). `size` and `color` default to
what the root resolved; passing either wins. Both `style` and the style props reach the
`source` form only — the other two are not views this renders.
### `Button.Spinner`
The `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | ---------------------- | ------- | ------------------------------------------ |
| `style` | `StyleProp` | — | |
| `animation` | `boolean` | `true` | `false` stops the rotation; the ring stays |
## Touch feedback
**A button scales, and mounts no overlay.** That is a decision rather than an omission: the
recipe already paints the variant's own `…Pressed` token under the finger, so a wash or a
wave on top would darken it twice. A component picks **one** pressed treatment, never both —
and there is no prop here to pick the other, because there is nothing to pick between.
`animation={false}` renders a different component rather than the same one with a branch
inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every
descendant, which is how a long list switches its rows off with one prop.
A button that genuinely needs a wave is a different component, not a prop away: compose it
from `PressableFeedback` directly, with a recipe whose `pressed` state paints nothing. See
the `PressableFeedback` page.
## Extending it
The context hook is exported, so a third party can write their own slot against the same
resolved values the built-in ones read:
```tsx
import { useButton } from '@xaui/native/button'
function ButtonBadge({ children }) {
const { labelStyle, isDisabled } = useButton()
return {children}
}
```
Used outside a `` it throws by name, pointing at the misplaced component rather
than failing three frames later on an undefined style.
## Accessibility
- `accessibilityRole` is `"button"` by default and stays overridable.
- `accessibilityState` carries `disabled` and `busy`, **merged** with any state you pass —
adding `expanded` or `selected` does not erase them.
- An icon-only button needs an `accessibilityLabel`. There is no text to announce, and the
component warns in development when one is missing.
- The label is real `Text`, so it is read, and it truncates rather than reflowing.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| ----------------------------------------- | ------------------------------------------------------ |
| `themeColor="primary" variant="solid"` | `variant="primary"` |
| `themeColor="danger" variant="solid"` | `variant="danger"` |
| `variant="bordered"` | `variant="tertiary"` |
| `variant="light"` | `variant="ghost"` |
| `variant="flat"` / `"faded"` | `variant="secondary"`, or the `-soft` of the intention |
| `startContent={ }` | ` ` before ` ` |
| `endContent={ }` | ` ` after ` ` |
| `customAppearance={{ container, label }}` | `style` on the root, `style` on `Button.Label` |
| `fullWidth` | removed — that is already the default in a column |
| `spinnerPlacement="end"` | compose ` ` after the label |
| `elevation` | `style={{ ...theme.shadows.surface }}` |
| ` ` | `` |
# Calendar
A month, and the day chosen in it.
## Import
```tsx
import { Calendar } from '@xaui/native/calendar'
```
## Anatomy
```tsx
```
| slot | what it is |
| ------------------------- | ------------------------------------------------------- |
| `Calendar` | State and resolved style. It draws no ground of its own |
| `Calendar.Header` | The row above the grid, and the glyph scale in it |
| `Calendar.Title` | The month, named through the locale |
| `Calendar.PreviousButton` | A month back. Dead at the bounds |
| `Calendar.NextButton` | A month forward |
| `Calendar.Weekdays` | The seven column headings |
| `Calendar.Grid` | Six weeks of cells |
| `Calendar.Day` | One day |
| `Calendar.YearPicker` | The years a calendar can be aimed at |
| `Calendar.MonthPicker` | The twelve months of the year just aimed at |
**The header lays itself out with `space-between` and nothing else.** Where the title sits —
between the chevrons, or beside them with both on the right, as `AgendaCalendar` has it — is
what the caller writes, not a prop.
## The month on screen is state of its own
```tsx
```
Paging through months is **not** choosing a day. A calendar that jumped back to the chosen
month every time you looked at the next one would be unusable, and one that chose a day
because you paged past it would be worse. So there are two pieces of state, and either can
be controlled without the other.
Uncontrolled, the month starts on the chosen day's — or on this one.
## The grid is always six weeks
Never five for a short month. A grid that changed height between March and April would move
everything under it twice a year, and the calendar would jump as you paged through it.
Days from the months either side fill the ends. They are **muted and still choosable**: a
calendar that refused the 1st of next month would be refusing a date you can see. What makes
a day inert is `minValue` / `maxValue`, not which month it belongs to.
## The week starts where the locale says
`Intl.Locale`'s week info answers it properly where it exists — Saturday-first locales are
real, and a hand-kept list of Monday-first languages has never included them. Most React
Native engines do not have it, so a list of Monday-first languages is the fallback rather
than the source.
`firstDayOfWeek={1}` overrides both. `locale` names the months and the weekdays, and defaults
to the device's.
## Cells of your own
```tsx
{date => (
{date.getDate()}
)}
```
**`Calendar.Grid` takes a function, and it is the one place in this library that does.**
Forty-two cells are generated from a month rather than written, so there is nothing for a
caller to compose against: `asChild` merges into one element, and a slot list cannot
enumerate a month.
**A day is a date plus the calendar around it.** `Calendar.Day` reads everything else off
its `date` — chosen, outside the month, out of bounds, today — which is what makes the
function above two lines rather than a wiring exercise. It is also what `AgendaCalendar`
builds its event dots on.
## The bounds
```tsx
```
Days outside them are muted and inert, and **the chevrons go dead**: a step that would land
on a month with no selectable day in it has nothing to show, and a chevron that stays lit
while it stops working is the worst of the three options.
Bounds are compared **by day, not by instant**. A `maxValue` written as `new Date()` carries
the current time, and an instant comparison would refuse the rest of today.
## Sizes
| `size` | Cell | Number | Title | Weekday |
| ------ | ---- | ------ | ----- | ------- |
| `sm` | 36 | 14/20 | 16/24 | 12/16 |
| `md` | 40 | 16/24 | 18/28 | 14/20 |
| `lg` | 44 | 18/28 | 20/30 | 16/24 |
**`size` is the cell's box, never the grid's width.** A calendar is seven columns wide
whatever the size, so a `size` that drove width would be deciding the calendar's own; the
grid spans its parent and each column takes a seventh of it.
`radius` is the circle `size` sets, and it moves the cell and the disc together — a
squared-off day holding a round disc is what the pair exists to prevent.
## Variants and colour
| `variant` | The chosen day | Its number |
| ----------- | -------------- | ---------------------- |
| `primary` | `accent` | `accentForeground` |
| `secondary` | `accentSoft` | `accentSoftForeground` |
| `tertiary` | `default` | `defaultForeground` |
| `ghost` | `foreground` | `background` |
Four emphasis levels and **no intent** — a date is neither a success nor a danger. What the
variant names is the chosen day, because a day at rest is the same on all four: a calendar is
a grid of numbers, and the variant is about the one that is answered.
The pair is `bgSelected` / `fgSelected` — the `Checkbox`'s roles, for the `Checkbox`'s
reason. Forty-two cells share one resolution and exactly one is chosen, so selection cannot
be a variant axis without resolving the recipe per cell; and it has to be a **role** or a raw
`color` would stop reaching the chosen day the moment it became the chosen one, since the
tint pass re-runs `paint` and never the axes.
`color` is a raw value (R7) and lands on the disc, with the number derived to read against it.
## Today
Today carries a small dot tucked just under its number — the **soft accent** on a plain day,
and the chosen day's own contrast colour when today _is_ the chosen day. It is positioned
absolutely so the number does not shift down the day the dot appears.
## The dates behind it
The arithmetic is `utils/dates.ts`, and it is tested: `startOfDay`, `isSameDay`,
`isSameMonth`, `addDays`, `addMonths`, `startOfWeek`, `isWithinBounds`, `monthGrid`,
`weekGrid`, `weekdayNames`, `monthLabel`, `monthNames`, `firstDayOfWeekFor`.
Two of those exist because the obvious version is wrong:
- **`addMonths` clamps to the end of the target month.** `new Date(2026, 0, 31)` plus a
month is the 31st of February, which `Date` rolls forward to the 3rd of March — so a
calendar stepping from January to February would land on March.
- **`addDays` goes through the day-of-month, not through milliseconds.** A day is not always
86 400 seconds, and adding that many across a daylight-saving boundary lands an hour into
the day before.
## Props
### `Calendar`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ---------------- | --------------------------- | ------------ | -------------------------------- |
| `variant` | `CalendarVariant` | `'primary'` | The chosen day |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The cell's box and its type |
| `radius` | `RadiusKey` | a circle | The cell and the disc together |
| `color` | `string` | — | A hex tint, on the chosen day |
| `value` | `Date` | — | Controlled |
| `defaultValue` | `Date` | — | Uncontrolled starting day |
| `onValueChange` | `(value: Date) => void` | — | Fires at midnight local time |
| `month` | `Date` | — | The month on screen. Controlled |
| `defaultMonth` | `Date` | the value's | Uncontrolled starting month |
| `onMonthChange` | `(month: Date) => void` | — | |
| `minValue` | `Date` | — | Compared by day |
| `maxValue` | `Date` | — | |
| `firstDayOfWeek` | `0`–`6` | the locale's | |
| `locale` | `string` | the device's | |
| `view` | `CalendarView` | — | Controlled panel on screen |
| `defaultView` | `CalendarView` | `'grid'` | Uncontrolled starting panel |
| `onViewChange` | `(v: CalendarView) => void` | — | Fires when a picker steps it |
| `isDisabled` | `boolean` | `false` | Every day, and none opts back in |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `Calendar.Day`
Everything `PressableFeedback` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ------------ | ----------- | ------- | ----------------------------------------- |
| `date` | `Date` | — | Required. Everything else follows from it |
| `children` | `ReactNode` | — | Replaces the number and the dot |
| `isDisabled` | `boolean` | — | Overrides what the bounds say |
### `Calendar.PreviousButton`, `.NextButton`
Everything `PressableFeedback` accepts, plus `step` — how many months one press moves.
### `Calendar.YearPicker`
A `ScrollView` of year pills, and everything it accepts plus `style` in `ViewStyle` keys
(R14), plus:
| Prop | Type | Default | Notes |
| ----------- | -------- | ------------- | ----------------------------------- |
| `firstYear` | `number` | fifty back | From `minValue`'s year when bounded |
| `lastYear` | `number` | fifty forward | From `maxValue`'s year when bounded |
### `Calendar.MonthPicker`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| -------- | ------------------- | -------- | --------------------------------------------- |
| `format` | `'long' \| 'short'` | `'long'` | `"septembre"` or `"sept."`, clipped to a line |
## Switching the month and the year
`Calendar.YearPicker` and `Calendar.MonthPicker` are the legacy dialog's month-year button
rebuilt in composition. Each mounts **instead of** the weekdays and the grid, not over them
— a drum of years behind a drum of days would read as two things at once — and the walk is
legacy's: the title opens the years, a year opens the months of that year, a month lands
back on the days.
`view` is the third piece of state, controlled the way `value` and `month` are. The pickers
step it themselves; the caller owns it so the title can be the switch:
```tsx
const [view, setView] = useState('grid')
setView(v => (v === 'grid' ? 'year' : 'grid'))}>
{view === 'year' ? (
) : view === 'month' ? (
) : (
<>
>
)}
```
The year and the month on screen each wear the chosen day's disc, and the year list opens
on the year it was on. Pressing a year **keeps the month of the year it was showing**, and
pressing a month **keeps the year** — a header that said "septembre 2026" has not become a
switch to January. A year or a month that lies entirely outside the bounds is dead rather
than merely inert.
The caller reads `view` to choose which of the three to render, so in practice it holds the
state — as above, or from a child on `useCalendar()`. The pickers only ever step it.
## Extending it
`useCalendar()` is exported (R10) and carries the resolved styles, the month on screen, the
chosen day, the locale, the panel (`view` / `setView`), and the moves: `select`,
`goToMonth`, `canGoToMonth`, `goToYear` and `goToMonthInYear`. Enough for a "Today" button
of your own, or a title on `useCalendar().setView` — the pickers the built-in ones serve run
on nothing else. Outside a `` it throws by name.
## Accessibility
- Each day is a `button` labelled with its **full date** — "dimanche 6 septembre 2026" —
because the number alone says "6", and carries `accessibilityState.selected`.
- The title is a `header`, which is what a screen reader jumps between.
- The weekday row is hidden from the accessibility tree: the names are for the eye, and each
day already announces the day it is.
- The chevrons need an `accessibilityLabel` from the caller: the month they go to is what
should be announced, and only the caller knows which language to say it in.
# Card
A surface that groups related content — and, with `isPressable`, the control that opens it.
## Import
```tsx
import { Card } from '@xaui/native/card'
```
## Anatomy
```tsx
```
- **`Card`** — the root. A `View`, or a `PressableFeedback` when `isPressable` is set. It
resolves the recipe once and publishes the resolved styles to its slots. A string child
is wrapped in a `Card.Description` automatically.
- **`Card.Header`** — the top section: a badge, an icon, an eyebrow. A column pinned to the
leading edge; `flexDirection="row"` makes it a title-and-action bar.
- **`Card.Body`** — the main section, and the one that grows. Given a card with a height,
it takes what the header and the footer leave.
- **`Card.Footer`** — the bottom section. A **row**, because a footer is an action row.
- **`Card.Title`** — the heading. It wraps, where `Button.Label` truncates: a card has no
fixed height to deform.
- **`Card.Description`** — the prose. It sits behind the title at 60% of the title's own
colour, which composites to the `muted` token on both surfaces and still holds on a
tinted card, where a fixed grey would stop being readable.
**Every slot reads the root's context, not its parent's.** `Card.Title` and
`Card.Description` are children of the card, not of the body, so the tree above is a
convention rather than a constraint — a title directly under `Card` styles identically.
Nesting them in the body is the form to prefer, because it is the body's gap that sets
them apart and the body that grows.
**No slot carries a margin** (R4). What separates the sections is the root's `gap`, so JSX
order is screen order and nothing has to be undone to reorder them. The sections are
optional and independent: a card that is a title and a description needs no footer.
## Usage
### Basic
```tsx
Facture #1024
Émise le 3 mars, échéance le 2 avril.
```
A stringifiable tree becomes a `Card.Description` on its own (R3) — `Description` and not
`Title`, because a card with prose and no heading is ordinary and the reverse is not:
```tsx
Une carte qui ne dit qu'une chose.
```
### Composed
```tsx
En retard
Facture #1024
Trois lignes, 1 240 € hors taxes.
Payer
Plus tard
```
Nothing is spaced by hand: the root's `gap` separates the three sections and the body's own
gap separates the title from the description. That is what R4 buys — the layout is the
component's, not a set of margin classes at the call site.
The header is a column and the footer a row, which are the two common cases. Either flips
with one style prop (R14):
```tsx
Activité
Voir
```
### Pressable
```tsx
Facture #1024
```
`isPressable` is a prop and not an inference from `onPress` being present, because the two
answers are different **elements** — a `View` and a `Pressable`. Inferring it would remount
the card, and change what a screen reader announces, on the render where a handler happens
to become `undefined`. A press handler written without it warns in development.
### As another element
```tsx
Facture #1024
```
Under `asChild` the caller's element **is** the card: it receives the ref, the styles and
the handlers. There is no auto-wrap and, on a pressable card, no press wash — the element
is the pressable, and there is no sibling to paint under it.
### Sizes
```tsx
```
`size` drives padding, both gaps, the radius and the type of `Title` and `Description` —
**never a height**. A card is a surface: it is as tall as what it holds, and as wide as its
parent lets it be. There is no `fullWidth`; `width="100%"` and `alignSelf` are the answer,
as everywhere else.
Two gaps move with it, not one. `gap` separates the sections; the gap inside a section
separates a title from its description. One value for both would read as a list of five
things rather than as three blocks.
### Variants
Four levels, and they are the `Button`'s own names — that is what makes this a subtype of
one vocabulary rather than a second one. A card **reports nothing**, so `success`,
`warning` and `danger` are absent: a card coloured by an outcome is a card holding a `Chip`
or an `Alert` that carries it.
| `variant` | Surface | Edge | Elevation |
| ----------- | ------------------ | -------- | ----------------- |
| `default` | `surface` | — | `shadows.surface` |
| `secondary` | `surfaceSecondary` | — | — |
| `tertiary` | — | `border` | — |
| `ghost` | — | — | — |
A filled card has **no border**: the fill and the elevation already separate it from the
background, and an edge on top of them is a third signal saying the same thing — which is
what makes a list of cards read as a table. `tertiary` names one because there it is the
only signal.
The elevation belongs to the one variant that is a surface standing on the background.
`secondary` is the level for a card _inside_ a card, and `tertiary` and `ghost` have no
fill to lift, so a shadow under any of them would read as dirt rather than as height. In
dark mode the theme's `surface` shadow is already nothing, which is why the recipe names
the role instead of a set of numbers.
### Colour
```tsx
…
…
```
One raw tint, placed by the variant: the fill of a `default`, the border of a `tertiary`,
the text of a `ghost`. Its contrasted slice is derived in OKLab, so the title stays readable
on the fill without a second colour being named — and the description follows, because it
is a fraction of the title's colour rather than a fixed grey.
### Style as props
```tsx
…
…
```
Full React Native names, so full React Native values: `padding={32}` is 32 points, never a
step on a scale. They resolve after the recipe and before the node's own `style`, and they
are scoped to the node they are written on — a `padding` on `Card.Body` pads the body, not
the card.
### Everything else goes through `style`
A tinted shadow, a gradient, a `transform`, a per-platform value: `style`, on the root or on
the slot that needs it. Two cases worth naming:
- **A full-bleed image** needs `overflow="hidden"` on the root, which the card does not set
itself: on iOS it clips the node's own shadow, and a `default` card would lose the
elevation its variant just gave it.
- **A border in a different colour than the fill** is `borderColor` and `borderWidth` as
style props — the variant names one token for the edge, and a second one is not a
variant.
### `Card.Background` — ce que la carte pose derrière elle
```tsx
Marrakech
```
**The root paints it first, wherever it is written.** JSX order decides stacking for
absolutely positioned siblings, so a background written after the header would cover it.
The root hoists the slot instead — the mechanism `PressableFeedback` already uses for its
overlays — which is why it is a marked component rather than a `View` the caller positions.
`markBackground` is exported, so a third party's gradient or video layer gets the same
treatment.
**The clip lives on the layer, not on the root.** `overflow: 'hidden'` cuts the node's own
shadow on iOS, so putting it on the card would cost a `default` card the elevation its
variant just gave it. This slot carries its own `overflow` and the card's radius — which is
also why `radius` moves both slots together. The reference implementation clips on both and loses the shadow.
Two forms, like `Icon`: `source` renders the image, and anything else is the caller's own
layer, already positioned and clipped.
## Props
### `Card`
Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts —
every `ViewStyle` key those two do not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ------------------- | ------------------------------ | ------------------------- | ------------------------------------------------------ |
| `variant` | `CardVariant` | `'default'` | The four levels above |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Padding, gaps, radius, type. Never a height |
| `radius` | `RadiusKey` | — | Overrides the radius `size` implies |
| `color` | `string` | — | A hex tint, placed by the variant |
| `isDisabled` | `boolean` | `false` | Dims; on a pressable card, also stops the touch |
| `isPressable` | `boolean` | `false` | Renders a `PressableFeedback` instead of a `View` |
| `asChild` | `boolean` | `false` | Merge into the single child instead of rendering |
| `animation` | `AnimationProp` | — | Pressable cards only; `false` mounts no worklet at all |
| `accessibilityRole` | `AccessibilityRole` | `'button'` when pressable | Overridable |
The press props are on the type unconditionally, because `isPressable` is read at runtime.
A static card is a `View` and ignores them — the component warns in development rather than
letting a card look broken.
### `Card.Header`, `Card.Body`, `Card.Footer`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14). The header is a column
aligned to the leading edge, the body grows, the footer is a row.
### `Card.Title`, `Card.Description`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14) — `fontSize`, `color`,
`numberOfLines`… Both wrap by default.
## Touch feedback
**A pressable card scales and takes a wash**, where the `Button` scales and uses its
variant's own `…Pressed` token. A component picks **one** pressed treatment, never both, and
the card picks the other one for two reasons: it has no pressed token per surface level to
swap to, and on an area this large a flat overlay says "under the finger" better than a fill
a shade darker does.
The wash contrasts with whatever the card is: the root reads the ink off its own resolved
background, so a tinted card, an outlined one and a ghost one each get an overlay that is
visible on them. It rounds itself to the card's corners, which is why the root needs no
`overflow: 'hidden'` — and must not have one, or the iOS shadow goes with it.
`animation={false}` renders a different component rather than the same one with a branch
inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every
descendant, which is how a list of cards switches its rows off with one prop.
## Extending it
The context hook is exported, so a third party can write their own slot against the same
resolved values the built-in ones read:
```tsx
import { useCard } from '@xaui/native/card'
function CardMedia({ source }) {
const { bodyStyle } = useCard()
return
}
```
Used outside a `` it throws by name, pointing at the misplaced component rather than
failing three frames later on an undefined style.
## Accessibility
- A **static** card has no role of its own and announces nothing: it is a box, and the text
inside it is what a screen reader reads. `accessibilityRole` is yours to set when the card
is a landmark.
- A **pressable** card is `accessibilityRole="button"` by default and stays overridable, and
its `accessibilityState` carries `disabled` **merged** with any state you pass — adding
`expanded` or `selected` does not erase it.
- A pressable card is one target. Nesting a `Button` inside one gives a screen reader two
overlapping controls and a sighted user an ambiguous tap — put the actions in the footer
of a _static_ card instead.
- `Title` and `Description` are real `Text`, so they are read and they reflow at large font
sizes rather than truncating.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| --------------------------------------------- | --------------------------------------------------------- |
| `themeColor="default"` | `variant="default"` |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `padding={16}` | `size`, or `padding={16}` as a style prop |
| `elevation={2}` | `variant="default"` — the surface shadow is the variant's |
| `fullWidth` | removed — that is already the default in a column |
| `isPressable` | `isPressable` — now a `PressableFeedback` |
| `isHoverable` | removed — there is no hover on a touch surface |
| `isBlurred` / `isFooterBlurred` | removed — a glass theme, not a card prop. Out of 1.0 |
| `disableAnimation` | `animation={false}` |
| `disableRipple` | removed — a card washes, it does not ripple |
| `customAppearance={{ container }}` | `style` on the root |
| `customAppearance={{ header, body, footer }}` | `style` on `Card.Header` / `.Body` / `.Footer` |
| `customAppearance={{ title, description }}` | `style` on `Card.Title` / `.Description` |
# Carousel
A series of slides, one or a few at a time, with the controls to move between them.
## Import
```tsx
import { Carousel } from '@xaui/native/carousel'
```
## Usage
```tsx
{photos.map(photo => (
))}
```
## The slides are yours and the controls are the library's
`variant`, `color` and the palette reach the arrows, the dots and the counter. **Nothing
here paints what is inside a `Carousel.Item`** — a carousel of photographs and a carousel of
cards want opposite things there, and the caller is the one who knows which this is.
That is also why there is no `renderItem` and no `data`: the slides are children, in JSX
order, and what is in one is whatever you put there.
## A slide's width comes from the track's, never from a prop
`itemsPerView` says how many whole slides are in view; `peek` says how much of the next one
shows at each edge. `carouselMetrics` divides the measured track between them:
```
├ peek ┼ gap ┼──── item ────┼ gap ┼──── item ────┼ gap ┼ peek ┤
```
A carousel whose slide width is given in points is a carousel that is wrong on the next
screen size — so it is not a prop, and the arithmetic lives in `utils/carousel.ts` where it
is read and tested rather than in the component where it is neither.
**The peek brings a gap of its own.** The run it costs at each edge is `peek + gap`, not
`peek`; without that second term the peeking neighbour touches the slide in view, and a
carousel whose slides touch reads as one wide image that has been cut.
Everything is clamped at zero. A track too narrow for the gaps and the peeks it was asked
for gives slides of no width rather than negative ones.
## Every control is a slot, not a prop
| slot | what it is |
| --------------------- | -------------------------------------------------- |
| `Carousel.Content` | the track |
| `Carousel.Item` | one slide |
| `Carousel.Previous` | back one, over the slides |
| `Carousel.Next` | on one |
| `Carousel.Indicator` | the row of dots |
| `Carousel.Dot` | one of them, for an indicator you compose yourself |
| `Carousel.Counter` | `3 / 12` |
| `Carousel.Thumbnails` | a strip of small versions of the slides |
| `Carousel.Thumbnail` | one of them |
A carousel that wants dots and no arrows leaves the arrows out. There is no
`showArrows={false}`, and there is no order to respect — the indicator above the track is a
` ` written first.
### The arrows are over the slides
An arrow in the flow would move the track it belongs to, and then the two would disagree
about where the middle is. They are centred on the **track's** own measured height, so a
carousel with dots under it does not have its arrows sitting low.
At the end of a series that does not loop an arrow **stays in place and goes quiet** —
disabled, not removed. A control that disappears at the last slide takes its width with it
and shifts everything beside it.
They need an `accessibilityLabel`, and there is a development warning when one is missing. A
chevron is not text, and unlike a labelled button there is nothing beside it to fall back on
— and no default, because which language it would be in is not this library's to decide.
### The dot is the pill
The chosen dot **grows in the row** rather than being covered by a wider node over it. An
overlay keeps the row's width fixed, so the pill spills over the dots either side and the
reader sees a lozenge with a grey dot half under it.
It follows the **drag**, not the settle: the width and the colour are interpolated from the
live scroll offset on the UI thread, so the pill grows out of one dot and into the next while
the finger is still down. That is what makes a reader believe the indicator is attached to
the thing they are moving.
A screen reader hears "3 of 5" through `accessibilityValue` rather than a label, for the
same reason the arrows have no default one.
### The thumbnails show what they point at
The one thing dots cannot do: on a series of photographs a reader picks the one they want
rather than counting across to it. The ring around the chosen one is always drawn and only
its colour moves — a border that appears on selection nudges every thumbnail after it along
the strip.
## The index
`index` / `defaultIndex` / `onIndexChange`, controlled or not, as everywhere in the library.
It changes as the track **crosses** the halfway point, not when it stops. `onMomentumScrollEnd`
would be the obvious place and it is the wrong one twice: it never fires for a wheel or a
trackpad, so under the web renderer the index would never move at all; and it lands after the
fact, so the arrows and the thumbnails would sit on the previous slide for the length of the
deceleration.
A fast flick across three slides therefore reports three changes. That is what happened.
## How a move is animated
A **drag** is the platform's: `snapToInterval` plus `decelerationRate="fast"`, so a flick
lands on a slide rather than between two.
An **arrow, a dot or an autoplay tick** is a hand-run tween — the offset is carried from
where it is to the next step over ~420ms against an ease-**out** curve, a frame at a time,
so the track leaves fast under the press and brakes onto the slide. `scrollTo({ animated:
true })` was the obvious call and it is close to linear on both renderers: it leaves and
arrives at the same speed and the move reads as a jump cut. A press mid-travel eases on
from the live position rather than racing the tween before it.
## `hasLoop` is about the arrows
Unset, they stop at the ends. Set, they wrap.
**Autoplay always wraps**, whatever `hasLoop` says. That prop answers "what does the arrow do
at the last slide"; an autoplay that stopped there would be one that quietly dies.
## `autoPlayInterval` stops at the first interaction
And does not come back. A carousel that resumes moving under a reader who has taken hold of it
is a carousel fighting them. A drag, an arrow, a dot or a thumbnail all count.
It is a number of milliseconds rather than a `hasAutoPlay` beside an interval, because the two
would only ever be set together.
## Variants
The four emphases, narrowed as `Card` and `Surface` narrow them: a carousel reports nothing —
it is a way of arranging what does — so `success` or `danger` would be an intent nothing here
has.
| variant | the arrows |
| ----------- | --------------------------- |
| `primary` | on `surface` |
| `secondary` | on `default` |
| `tertiary` | on `surface`, with a border |
| `ghost` | no ground at all |
The arrows sit **on** a slide, so their ground is the raised one rather than the page's: a
control the same colour as the page vanishes over a pale photograph. `ghost` is what a
carousel over a dark photograph wants and what one over a white photograph must not use.
`color` lands where the variant put its roles — the active pill and the chevron. The dots at
rest keep their neutral fill: they are the ground the pill travels on, and a pale tint of the
accent there reads as a control that has half failed to load.
## Size
`size` moves the arrows, the dots, the gap between slides, the thumbnails and the counter's
type — **never a slide**. A slide's width is the track's; its height is whatever is in it.
## Accessibility
The arrows are buttons and carry `accessibilityState.disabled` at the ends. The dots and the
thumbnails carry `accessibilityValue` — position and total — and `accessibilityState.selected`.
What is _in_ a slide or a thumbnail is yours, so an image with its own `accessibilityLabel` is
how a slide says what it shows.
## Props
Every node takes R14's style props for its own style type and forwards `ref`, `testID` and the
a11y props.
`useCarousel()` is exported: it returns the resolved styles, the metrics, the index, the count
and the live scroll offset — so an indicator of your own is written the same way the shipped
one is.
# Charts
Five figures — `LineChart`, `AreaChart`, `BarChart`, `PieChart`, `RadarChart` — and the plot
the first three share.
## Import
```tsx
import { Chart } from '@xaui/native/chart'
import { LineChart } from '@xaui/native/line-chart'
import { AreaChart } from '@xaui/native/area-chart'
import { BarChart } from '@xaui/native/bar-chart'
import { PieChart } from '@xaui/native/pie-chart'
import { RadarChart } from '@xaui/native/radar-chart'
```
## The frame
A figure on a screen is a card with words around it, and those words are a title, a
subtitle, a number and a legend. `Chart` is that card:
```tsx
Source du trafic
Sessions
Douze derniers mois
```
| slot | what it is |
| ------------------- | ------------------------------------------------------- |
| `Chart` | The card, and the palette everything in it shares |
| `Chart.Header` | The row above the figure, pushed apart |
| `Chart.Heading` | The title, its description and a value, as one block |
| `Chart.Title` | What the figure is. A `header` to a screen reader |
| `Chart.Description` | What it is measuring — the unit, the period, the caveat |
| `Chart.Value` | The one number it is about, when it is about one |
| `Chart.Legend` | Which colour is which series |
| `Chart.LegendItem` | One entry of it |
| `Chart.Footer` | The row under the figure. It wraps |
**It is optional in both directions.** A figure on its own is the shape all five shipped as,
and it draws no ground — so ` ` in a caller's own `Card` is a complete use. And a
frame with **no figure in it** is a complete use too: a card with a title, a value and a
footer is what a chart looks like while its data is loading, or when there is none.
```tsx
Sessions
Aucune donnée sur la période
```
Neither half asks for the other, which is what makes the pair worth having.
**The frame owns the appearance and the figure takes it.** `variant`, `size` and `color` are
handed down, so the legend's dots and the figure's series are the same colours in the same
order without either being told twice — which is the whole reason `Chart.Legend` can exist
rather than being a prop on a figure. A figure that names its own still wins.
`seriesCount` is the one number the frame asks for: it cannot count the figure's series,
because it has not rendered the figure and the keys are that figure's props, and a legend
needs the palette walked to the right length or its third dot is the wrong colour.
**The labels stay the caller's.** What a series is called is a sentence in their language;
which colour it got is arithmetic the palette already did. `labels` is the short form and
children are the long one — a legend carrying a value and a share beside each name, which is
what a donut usually wants under it.
`Chart.Heading` exists for `ProgressBar.Header`'s reason: the gap between a title and its
subtitle is a different gap from the one between that block and the legend beside it, and two
gaps belong to two roots.
## They are drawn here, not wrapped
**Nothing is imported to draw them.** `react-native-svg` is already an optional peer — the
`Select`'s check and the `Icon`'s chevron use it — and everything above it is this library's:
the scales, the paths, the palette.
That is not an aesthetic preference. A chart library is a second design system: it has its
own idea of a colour, its own appearance blob, its own units, and its API is the one place
`variant` and `color` cannot reach. Wrapping one means either exposing that blob — which is
`customAppearance`, the thing R2 removed — or fighting it. The alternative was a native
graphics engine as a peer dependency, which is a lot of install for four figures.
**And the maths becomes tests.** `chart-scale`, `chart-path` and `chart-palette` are 106
cases. A curve that must never dip below its data, an axis that must never label a value
above the tallest bar, a bar whose corner must not exceed its own height — all of that is a
test rather than a screenshot someone has to remember to look at.
## The API is chartkit's shape
```tsx
```
Rows of objects, a key for the axis, a key per series. The one difference is **`yKeys`
plural**, because a chart with two series is the common case and not an escape hatch.
**The series are props, not children**, and that is the single place this family parts
company with the rest of the library. A line is not a component a caller composes — it is a
column of their data. What composition there is lives on `ChartPlot`, which takes a render
function for the case a caller needs a mark the five do not draw.
## A shade per series, not a colour per series
The palette is walked out of **one** colour — the variant's ink, or `color` when a caller
gives one — in OKLab lightness.
A chart's series are usually one quantity split: organic and paid traffic, mobile and
desktop. Shades of one colour say "parts of a whole" where a rainbow says "unrelated
things", and it is the only scheme that survives a caller changing the accent, because there
is nothing to change but the seed.
The walk **reduces chroma to stay in gamut** rather than clamping channels. Clamping gives
away the hue — the channel that overflowed stops moving while the others keep going, and a
blue drifts several degrees towards cyan across a ramp — where pulling chroma in keeps the
angle exactly and gives away only saturation, which is what a lighter shade of one colour is
anyway.
## The plot
`LineChart`, `AreaChart` and `BarChart` are three files over one `ChartPlot`, the way the
`Autocomplete` is a few files over `selectRecipe`. Three charts with three axis tables would
be three to keep in step, and the drift would show as a line chart and a bar chart on one
dashboard with labels at two sizes.
It owns the frame, the grid, the axes, both scales and the palette. `PieChart` and
`RadarChart` are square rather than framed, so they take the palette and the ink and draw
their own geometry.
### The axis is honest
`niceScale` picks a step from 1, 2, 2.5, 5 or 10 times a power of ten, then **widens the
domain** to a multiple of it. The other way round — squeezing the ticks into the data's own
range — is what produces an axis labelled 3.33 and 6.67, and an axis whose top tick sits
below the tallest bar is an axis that lies about the data.
`tickCount` is a target, not a count.
### Point spacing and band spacing
A **bar occupies a slot**, so it is centred in one and the ends of the plot are empty. A
**line connects readings**, so the first and the last sit on the plot's edges. A line inset
by half a slot at either end reads as a chart that has been cut off; a bar on the edge is
half outside the plot.
Two scales rather than one with a flag, because they are two shapes. Each chart asks for the
one it is.
### The labels are React Native
`` beside the canvas rather than inside it, so they take the theme's font, its scaling
and its colour. `` inside an `` needs a font file loaded and ignores the
platform's text size.
The plot reserves 40 points for the y labels and 20 for the x. A label of "1 200 000" is the
case that loses to, and `formatY` is the answer — which is why it exists.
## The curve never leaves its data
`monotone` is Fritsch–Carlson: the tangent at each point starts as the average of the slopes
either side of it, is **flattened to zero wherever the data turns**, and is capped at three
times the neighbouring slope elsewhere. That cap is the condition under which a cubic Hermite
segment cannot leave the interval its endpoints set.
The alternative — a control point at the x-midpoint — is four lines and overshoots: two high
readings either side of a low one bow the curve below the low one, and on an area chart that
is ink under the axis.
`curve="linear"` for straight edges.
## Variants and colour
| `variant` | The series' ink |
| ----------- | --------------- |
| `primary` | `accent` |
| `secondary` | `foreground` |
| `success` | `success` |
| `warning` | `warning` |
| `danger` | `danger` |
The `ProgressBar`'s five, for the `ProgressBar`'s reasons: a chart reports a quantity, so
`primary` and `secondary` are its two emphases and the three intents are for when the number
itself is the news. `tertiary` and `ghost` are gone — a series with no ink is not a series —
and the `*-soft` pairs with them, since a chart's fills are already soft.
The ink is `bgSelected`, a **role** rather than a token named in an axis: the tint pass
re-runs `paint` and never the axes, so a series colour written as an axis would snap back to
the accent the moment a caller set `color`, and the palette walked out of it would go too.
**No figure paints its own ground.** One that did would be a card inside the `Chart` frame,
or inside the caller's own `Card` where there is no frame.
## Sizes
| `size` | Plot height | Ring / web |
| ------ | ----------- | ---------- |
| `sm` | 140 | 140 / 180 |
| `md` | 200 | 200 / 240 |
| `lg` | 280 | 260 / 300 |
**`size` is the plot's height, never its width.** A chart spans its parent — RN's own
behaviour, and the reason there is no `fullWidth` — and how tall it is is the only thing a
caller cannot infer from the column it sits in. A pie and a radar are square, so for those it
is a diameter.
## Shared props
Every cartesian chart takes these, plus everything `View` accepts and the `ViewStyle` keys as
props (R14):
| Prop | Type | Default | Notes |
| ----------------- | --------------------------- | ----------- | ---------------------------------------------------- |
| `data` | `Data[]` | — | An empty list draws the axes |
| `xKey` | `keyof Data` | — | What runs along the bottom |
| `yKeys` | `(keyof Data)[]` | — | The series, in palette order |
| `variant` | `ChartVariant` | `'primary'` | |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The plot's height |
| `color` | `string` | — | The palette's seed |
| `hasGrid` | `boolean` | `true` | |
| `hasXAxis` | `boolean` | `true` | |
| `hasYAxis` | `boolean` | `true` | |
| `tickCount` | `number` | `4` | A target, not a count |
| `xLabelCount` | `number` | what fits | Every nth row is labelled |
| `formatX` | `(value, index) => string` | — | |
| `formatY` | `(value: number) => string` | — | |
| `hasZeroBaseline` | `boolean` | `true` | An axis from the data's floor lies about proportions |
| `isDisabled` | `boolean` | `false` | |
## Extending them
`ChartPlot` is exported. It takes the same props and a **render function** that receives the
scaled series, the frame, both scales and the ticks — enough to draw a mark none of the five
does, on the same axes as the ones that do.
```tsx
{({ series, frame }) => (
)}
```
`chart-scale` and `chart-path` are `utils/` — private, and deliberately: they are the
family's own maths, not an API. A chart of your own reaches for `ChartPlot`.
# Checkbox
A box that is either ticked or not, with the label that says what it means.
## Import
```tsx
import { Checkbox } from '@xaui/native/checkbox'
```
## Anatomy
```tsx
```
- **`Checkbox`** — the root, and it is the **row, not the box**. It is the pressable: it
owns the selected state, resolves the recipe once (R5) and publishes it to its slots.
- **`Checkbox.Indicator`** — the box, and the mark inside it. With no children it draws its
own check.
- **`Checkbox.Label`** — what ticking the box means.
**The root is the row** so that tapping the label toggles the checkbox. That is the whole
reason the label is a slot here rather than a `Text` you put beside the component and wire
up yourself — and it is why `PressableProps` are on the root.
**R3 — and the box comes with the auto-wrap.** A stringifiable tree becomes the label _and_
the root supplies the indicator, because a checkbox without one is not a checkbox:
```tsx
J'accepte
// is exactly
J'accepte
```
Written with no children at all, it is the box alone — the form you want inside a table
row, where the label is a cell of its own. Anything else and the arrangement is yours.
**No slot carries a margin** (R4). What separates the box from its label is the root's
`gap`, so JSX order is screen order — a label before the box is a matter of where you
wrote it.
## Usage
### Controlled, or not
```tsx
J'accepte les conditions
Recevoir la lettre d'information
```
`isSelected` present means the caller owns the value; `defaultSelected` means the checkbox
keeps it. `onSelectedChange` fires either way, and so does `onPress` — the tick and your
handler both happen, in that order.
### Invalid
```tsx
Il faut accepter pour continuer
```
The border, the mark's fill and the label turn `danger`, and the **resting fill is
dropped**: a box that is wrong reads as an outline rather than as a filled control that
happens to be red at the edge. The reference implementation reaches the same shape with a compound.
It does not mount a message. Put one under the row yourself — a slot that silently renders
nothing is a slot you cannot debug, which is the `TextField.Error` bargain again.
### Indeterminate
```tsx
Tout sélectionner
```
Neither ticked nor empty — the state a "select all" sits in while some of its rows are.
The box fills as if selected and the mark is a dash, a screen reader hears `mixed`, and a
press resolves it to **selected** rather than toggling into it: "some of these" is a state
a control reports, never one a person picks.
It is a display state and does not touch `isSelected` — the caller decides when the
tri-state collapses, because only the caller knows what the rows say.
### Disabled
```tsx
Verrouillé
```
On the row, not the box: what is disabled is the control, and the label is part of it. R8
keeps `disabled` off the public surface.
### A label that wraps
```tsx
J'accepte que ces informations soient conservées…
```
The box centres on the row, which is right for one line and wrong for a paragraph. A style
prop moves it (R14) — no second API for it.
### A mark of your own
```tsx
```
Children replace the built-in check and ride the same fade. The built-in one exists so a
checkbox works in a project that has installed no icon set — `@xaui/icons` was deleted in
P0 — not to stop you having your own.
### As another element
```tsx
…
```
R12 — the caller's element **is** the row, so it takes the children it was written with and
the auto-wrap does not apply.
## Sizes
| `size` | Box | Corner | Check | Gap | Label |
| ------ | --- | -------- | ------------ | --- | ----- |
| `sm` | 20 | `sm` (6) | 10 × 5 · 2 | 8 | 14/20 |
| `md` | 24 | `md` (9) | 12 × 6 · 2 | 8 | 16/24 |
| `lg` | 28 | `md` (9) | 14 × 7 · 2.5 | 10 | 18/28 |
`size` drives the box, the glyph, the gap and the label's type — **never width**. A
checkbox hugs its label; a row that has to fill its parent is a style prop away.
The check is **derived from the box** — half its width, a quarter its height — rather than
tabulated, so it is one glyph at four sizes instead of four drawings of one.
`md` is the reference implementation's checkbox measured: a 24pt box with the field's 1pt border. The corner is
`md` (9) where theirs is `lg` (8) — the same key is 12 on our radius base, and 12 on a 24pt
box is a circle, which is the `Radio`.
## Variants
Three of the `TextField`'s four levels, meaning here what they mean there — this is the
`field*` family again, on a box 24pt wide instead of a field 48pt tall.
| `variant` | Box at rest | Border | Shadow |
| ----------- | ----------------- | ------------- | ------- |
| `primary` | `fieldBackground` | `fieldBorder` | `field` |
| `secondary` | `default` | `fieldBorder` | — |
| `tertiary` | transparent | `fieldBorder` | — |
**`ghost` is absent**, where the `TextField` has it: a field with no border is still a line of
text you can see; a checkbox with no border and no fill is nothing at all.
**A variant describes the box at rest.** Ticked, all three are the accent — or `color`.
## Colour
```tsx
Teinté
```
A raw tint, never a token (R7), and on this component it is **the colour the box checks
in**, with a mark derived to read against it.
That works because the selected fill is a **role** — `bgSelected`, with `fgSelected` for
the mark — rather than a token named in an `isSelected` axis. The tint pass re-runs `paint`
and the states, never the axes, so a fill written as an axis would have been the accent
again the moment the box was ticked. `Radio` and `Switch` need the same pair, which is why
it is in the engine rather than here.
**The tint is ignored while `isInvalid`.** An error outranks a brand colour, the same way it
outranks focus on the `TextField` — and unlike the `TextField`, where a tint and an error currently
fight over the border, this one is decided in the root and not left to resolution order.
## Selection is not a style axis
`isSelected` never reaches the recipe. The fill and the mark are two slots the indicator
**mounts only while it is ticked**, painted unconditionally from the roles above.
That buys two things at once: the style cache keeps one entry per token combination instead
of two, and `color` reaches the selected fill — which an axis would have skipped.
## Animation
The fill fades and grows in over 120ms, and the mark rides with it: a check arriving before
its background reads as a glitch rather than as an animation. ` ` turns it off, and then the Reanimated hooks are never reached at all —
two components rather than a branch inside one.
The press treatment is `PressableFeedback`'s, so `animation` on the root is the library's
usual knob: `false`, `'disabled'`, `'disable-all'`, or the object.
## Alignment with the reference implementation
Measured against their `checkbox.tsx` and `checkbox.css` rather than eyeballed.
**Identical:** the 24pt box, the field border and its width, the `field` fill plus shadow on
`primary` and the neutral `default` on `secondary`, the accent fill with an
`accentForeground` mark, the danger treatment including the dropped resting fill, the fade
and scale on the mark, and a built-in check so no icon set is required.
**Four deltas:**
| Theirs | Ours | Why |
| ------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| The root is the box; a label needs `ControlField` | The root is the row, `Label` is a slot | The plan's slots for this component are Indicator · Label, and a label that does not toggle the box is a bug waiting to be filed. |
| `variant` auto-switches on a surface | `secondary` by default | We have no surface detection, and the `TextField` already answers this the same way. |
| One size | Four | Every control in this library has the same ladder. |
| `Checkbox.Background` (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. |
**We have `isIndeterminate` and they do not.** The legacy `@xaui/native-legacy` checkbox
had it, a "select all" is the reason it exists, and `accessibilityState.checked: 'mixed'` is
a thing only the component can say.
## Props
### `Checkbox`
Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim
(R14), plus:
| Prop | Type | Default | Notes |
| ------------------ | ------------------------------- | ------------- | --------------------------------------------- |
| `variant` | `CheckboxVariant` | `'secondary'` | The box at rest |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Box, glyph, gap, label type |
| `radius` | `RadiusKey` | — | Overrides the corner the size chose |
| `color` | `string` | — | A hex tint — the colour it checks in |
| `isSelected` | `boolean` | — | Controlled |
| `defaultSelected` | `boolean` | `false` | Uncontrolled starting value |
| `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires on every tick, either mode |
| `isIndeterminate` | `boolean` | `false` | The third state — a dash, and `mixed` in a11y |
| `isInvalid` | `boolean` | `false` | Danger colours, and the tint is ignored |
| `isDisabled` | `boolean` | `false` | Dims the row and stops the press |
| `animation` | `AnimationProp` | — | The press treatment, as everywhere else |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `Checkbox.Indicator`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | ----------- | ------- | ------------------------------------------------ |
| `children` | `ReactNode` | — | Replaces the built-in check, inside the fill |
| `animation` | `boolean` | `true` | `false` shows the mark with no fade and no scale |
### `Checkbox.Label`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14).
## Extending it
The context hook is exported, so a third party can write its own slot against the same
resolved values the built-in ones read:
```tsx
import { useCheckbox } from '@xaui/native/checkbox'
function CheckboxDescription({ children }) {
const { isInvalid } = useCheckbox()
return {children}
}
```
Used outside a `` it throws by name, pointing at the misplaced component rather
than failing three frames later on an undefined style.
## Accessibility
- `accessibilityRole="checkbox"` on the root, overridable, with
`accessibilityState.checked` following the value and `.disabled` following `isDisabled`.
A caller's own `accessibilityState` is **merged**, not spread over: naming one key must
not silently drop the two a screen reader reads this control by.
- `aria-invalid` follows `isInvalid`.
- **The label is inside the control**, so the checkbox announces itself with its text
instead of as an unnamed box. A checkbox with no label needs an `accessibilityLabel`.
- The whole row is the touch target, which is what makes a 24pt box reachable; `hitSlop` is
`Pressable`'s and still available for the case where it is not.
- **The built-in check mirrors under RTL**, because `borderStartWidth` is a logical edge and
R13 leaves no other kind. A check reads as a check either way.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| ----------------------------- | -------------------------------------------------------------------- |
| `label="…"` | `… ` — the text child _is_ the label |
| `labelAlignment="left"` | write `` before `` |
| `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props |
| `isChecked` / `onValueChange` | `isSelected` / `onSelectedChange`, plus `defaultSelected` |
| `isIndeterminate` | `isIndeterminate` — unchanged, and now `mixed` in a11y too |
| `isDisabled` | unchanged, on the root |
| `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) |
| `variant="filled"` | `variant="tertiary"` — a border at rest, the accent once ticked |
| `variant="light"` | gone: a mark with no box. `` with your own glyph |
| `size="sm" \| "md" \| "lg"` | `size` — the same three |
| `radius` | `radius` — a `RadiusKey` now, not the legacy `Radius` |
| `fullWidth` | `width="100%"` in style props (R14) |
| `labelStyle` | `style` on `` |
| `style` | `style` on the root |
# Chip
A compact token — a status, a tag, a filter, a person.
## Import
```tsx
import { Chip } from '@xaui/native/chip'
```
## Anatomy
```tsx
```
- **`Chip`** — the root. A `View`, or a `PressableFeedback` when `isPressable` is set. It
resolves the recipe once and publishes the resolved styles to its slots. A string child
is wrapped in a `Chip.Label` automatically, which is the majority case:
`Payée ` is the whole component most of the time.
- **`Chip.Label`** — the text. Single-line by default, like `Button.Label` and for the same
reason: a chip has a fixed height, so a label too long for it truncates rather than
deforming the capsule.
- **`Chip.Dot`** — the status mark. A filled circle in the variant's foreground, sized by
the chip.
- **`Chip.Icon`** — a glyph that takes the chip's size and colour without being told
either. The three forms of `Icon`: `as`, a raw SVG child, or `source`.
- **`Chip.Avatar`** — a round, chip-sized frame: `source` renders an image, anything else
is centred and clipped the same way. Its diameter always fits inside the chip's height.
- **`Chip.Close`** — the dismiss affordance, and **a control in its own right**. It owns
its press state rather than reading the chip's, and with no children it draws its own
cross — so a dismissible chip works in a project that has installed no icon set.
**JSX order is screen order** (R4). No slot carries a margin: what separates a dot from its
label, or a label from its cross, is the root's `gap`. That is why there is no
`startContent` / `endContent` — a leading cross and a trailing one differ by where you
wrote the slot, and nothing has to be undone to swap them.
## Usage
### Basic
```tsx
Nouveau
Payée
```
### Composed
```tsx
En cours
Amina
Expire demain
```
### Pressable
```tsx
Design
```
`isPressable` is a prop rather than an inference from `onPress` being present, because the
two answers are different **elements** — a `View` and a `Pressable`. Inferring it would
remount the chip, and change what a screen reader announces, on the render where a handler
happens to become `undefined`.
A chip carrying only a `Chip.Close` stays static: the control is the cross, not the chip
around it.
### Dismissible
```tsx
{
tags.map(tag => (
{tag}
remove(tag)}
/>
))
}
```
Pass a child to replace the built-in cross:
```tsx
```
### As another element
```tsx
Design
```
The caller's element **is** the chip, so the auto-wrap does not apply — write the label
yourself, or a slot, inside it.
### Sizes
`size` drives the height, the horizontal padding, the gap and the type — **never the
width**. A chip hugs its label: `alignSelf` is `flex-start`, so a chip in a column stays
the width of its text instead of stretching, which is the one place its layout deliberately
differs from a `Button`'s.
| `size` | Height | Padding | Label | Dot | Avatar |
| ------ | ------ | ------- | ----- | --- | ------ |
| `xs` | 20 | 8 | 12/16 | 4 | 14 |
| `sm` | 24 | 10 | 12/16 | 5 | 18 |
| `md` | 28 | 12 | 14/20 | 6 | 22 |
| `lg` | 36 | 16 | 16/24 | 8 | 28 |
The height is fixed where the reference implementation uses vertical padding. Same numbers — theirs resolve to
20, 28 and 36 — and a different reason to arrive at them: with padding, a chip carrying an
avatar is taller than the chip beside it carrying only text, and a row of filters stops
lining up.
### Variants
Eleven flat names, replacing the reference implementation's `variant × color` matrix — four emphases times five
intents, of which nine combinations paint the same thing.
The first five are the `Button`'s ladder, descending by how much accent is left. The six
that follow are the three status families the `Button` deliberately refused: a button is
something you press and neither a success nor a warning is an action, while a chip
**reports** — the outcome _is_ what it says.
| `variant` | Background | Border | Text |
| -------------- | ------------- | -------- | ----------------------- |
| `primary` | `accent` | — | `accentForeground` |
| `secondary` | `accentSoft` | — | `accentSoftForeground` |
| `default` | `default` | — | `defaultForeground` |
| `tertiary` | transparent | `border` | `foreground` |
| `ghost` | transparent | — | `foreground` |
| `success` | `success` | — | `successForeground` |
| `success-soft` | `successSoft` | — | `successSoftForeground` |
| `warning` | `warning` | — | `warningForeground` |
| `warning-soft` | `warningSoft` | — | `warningSoftForeground` |
| `danger` | `danger` | — | `dangerForeground` |
| `danger-soft` | `dangerSoft` | — | `dangerSoftForeground` |
The dot and the cross take the **text** colour, not a status token of their own: on a
filled `success` chip the only readable colour is the one the label already uses, and on a
`success-soft` one that token _is_ the green. One rule, eleven variants, and a tinted chip
gets it for free.
### Colour
```tsx
Sprint 12
Sprint 12
```
A raw tint, never a token (R7). Where it lands follows the variant, exactly as on a
`Button`: the fill of a `primary`, the border and text of a `tertiary`, the text of a
`ghost`. Its soft, contrasted and pressed slices are derived in OKLab, so the dot and the
cross follow without being told a second colour — and so a tinted chip presses the same way
a token one does.
It resolves outside the style cache, which is why it must be a hex value and why it is the
one prop that allocates per render.
### Style as props
Every node takes its own style keys as props (R14) — full React Native names, full React
Native values, no hidden scale:
```tsx
Un libellé très long qui sera tronqué
En ligne
```
A dot that reports something other than what its chip does is the case `backgroundColor`
exists for. `padding={16}` is 16 points; reach for the scale explicitly with
`padding={theme.spacing(4)}`.
### Everything else goes through `style`
- **A gradient fill, a tinted shadow, a border in a colour the fill does not imply** — the
variant names one token for the edge, and a second one is not a variant.
- **`radius`** overrides the capsule. A chip is a pill at every size, which is what the
name means, so this is the escape hatch for the tag that wants corners.
## Props
### `Chip`
Everything `PressableFeedback` accepts — and therefore everything `Pressable` accepts —
every `ViewStyle` key those two do not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ------------------- | ------------------------------ | ------------------------- | ------------------------------------------------- |
| `variant` | `ChipVariant` | `'primary'` | The eleven names above |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, padding, gap, type. Never width |
| `radius` | `RadiusKey` | — | Overrides the capsule |
| `color` | `string` | — | A hex tint, placed by the variant |
| `isDisabled` | `boolean` | `false` | Dims, stops a pressable chip, reaches its close |
| `isPressable` | `boolean` | `false` | Renders a `PressableFeedback` instead of a `View` |
| `asChild` | `boolean` | `false` | Merge into the single child instead of rendering |
| `animation` | `AnimationProp` | — | Pressable chips only; `false` mounts no worklet |
| `accessibilityRole` | `AccessibilityRole` | `'button'` when pressable | Overridable |
The press props are on the type unconditionally, because `isPressable` is read at runtime.
A static chip is a `View` and ignores them — the component warns in development rather than
letting a chip look broken.
### `Chip.Label`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14). `numberOfLines` is `1`
by default.
### `Chip.Dot`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14). A circle sized by the
chip and filled with its foreground.
### `Chip.Icon`
The three forms of `Icon` — `as`, a raw SVG child, `source`. `size` and `color` default to
what the root resolved and an explicit value wins.
### `Chip.Avatar`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus `source` for the
image form. It is a frame, not the `Avatar` component of P3.11 — when that lands, this slot
becomes its call site rather than a second implementation.
### `Chip.Close`
Everything `PressableFeedback` accepts, plus the `ViewStyle` keys as props (R14).
`accessibilityRole` is `'button'` and `hitSlop` is `8` by default; `isDisabled` falls back
to the chip's.
## Touch feedback
**A pressed chip scales and its fill goes one step down**, using the variant's own
`…Pressed` token — the `Button`'s treatment, not the `Card`'s. A component picks **one**
pressed treatment, never both, and a chip picks this one because a wash over something this
small reads as a smudge, while the fill moving reads as the thing itself being pushed.
`Chip.Close` owns a press state of its own. The chip may be a `View`, and even when it is
pressable the two are different targets — pressing the cross must not read as pressing the
chip around it. `hitSlop` grows the cross's target outwards, because a cross big enough to
hit is a cross too big to look right.
`animation={false}` renders a different component rather than the same one with a branch
inside — no Reanimated hook is reached at all. `'disable-all'` does the same for every
descendant, which is how a long list of chips switches its rows off with one prop.
## Extending it
The context hook is exported, so a third party can write their own slot against the same
resolved values the built-in ones read:
```tsx
import { useChip } from '@xaui/native/chip'
function ChipCount({ children }) {
const { labelStyle } = useChip()
return {children}
}
```
Used outside a `` it throws by name, pointing at the misplaced component rather than
failing three frames later on an undefined style.
## Accessibility
- A **static** chip has no role of its own and announces nothing: it is a label, and the
text inside it is what a screen reader reads.
- A **pressable** chip is `accessibilityRole="button"` by default and stays overridable, and
its `accessibilityState` carries `disabled` **merged** with any state you pass — adding
`selected`, which is what a filter chip announces, does not erase it.
- **`Chip.Close` needs an `accessibilityLabel`**, and warns in development without one. A
cross is not text, and unlike an icon-only button there is nothing to fall back on: the
label beside it names what is being removed, not the action.
- **A dot is decoration.** A chip whose colour or dot is its only meaning is unreadable to
a screen reader and to anyone who does not distinguish the hue — put the status in the
label.
- A pressable chip is one target. Putting a `Chip.Close` inside one gives a screen reader
two overlapping controls; it works, and it is worth asking whether the chip needs to be
pressable at all.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| ------------------------------------ | --------------------------------------------------------- |
| `variant="solid"` + `themeColor` | `variant="primary"` / `"success"` / `"danger"`… |
| `variant="flat"` / `"faded"` | the `-soft` slice: `variant="success-soft"` |
| `variant="bordered"` | `variant="tertiary"` |
| `variant="light"` | `variant="ghost"` |
| `variant="dot"` | `variant="tertiary"` + ` ` |
| `variant="shadow"` | removed — a chip is not a surface. `style` if you need it |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `size="sm" \| "md" \| "lg"` | `size` — now `xs` … `lg`, and `xs` is the legacy `sm` |
| `radius="none"` | `radius="xs"` |
| `avatar={ }` | ` ` |
| `startContent` / `endContent` | a slot, written before or after `Chip.Label` |
| `onClose` | ` ` — composed, so you place it |
| `onPress` | `isPressable` + `onPress` |
| `customAppearance={{ container }}` | `style` on the root |
| `customAppearance={{ text }}` | `style` on `Chip.Label` |
| `customAppearance={{ dot }}` | `style` on `Chip.Dot` |
| `customAppearance={{ closeButton }}` | `style` on `Chip.Close` |
| `ChipGroup` / `ChipItem` | not in the 1.0 core — the group lands with P5 |
# CloseButton
The way out, on its own.
## Import
```tsx
import { CloseButton } from '@xaui/native/close-button'
```
## Anatomy
```tsx
```
No slots. There is one node and one mark in it, and a component with a single child has
nothing to publish a context for.
**It is the affordance `Chip`, `Alert`, `Dialog`, `Popover` and `BottomSheet` already have,
given a recipe of its own.** Those five keep theirs: a close _inside_ a component takes that
component's colours and that component's scale, resolved once at its root (R5), and
`Chip.Close` is five lines over the shared base for exactly that reason. What was missing is
this one — a dismiss on something the library does not own: a card header, a banner, a sheet
of your own.
## The base, and the component
Two things share the name, and the split is the point:
| | Where | What it is |
| ----------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `CloseButtonBase` | `@xaui/native/system` | The behaviour: its own press state, the grown target, the missing-label warning, the built-in cross. Takes the styles a host recipe resolved. |
| `CloseButton` | `@xaui/native/close-button` | The component: the base plus a recipe — a box that follows `size`, a disc that follows `variant`, and a `color` that tints it. |
Writing a dismissible component of your own means reaching for the base and publishing
`close` / `closeGlyph` from your recipe, exactly as `Chip` does. Dropping a dismiss into a
layout means reaching for the component.
## Usage
### The disc, and the bare cross
```tsx
```
`secondary` — the neutral disc — is the default, for the reason the `Dialog` gives at its
own close: a cross floating on a panel with nothing under it reads as decoration, and the
disc is what makes it a target. `ghost` is the bare cross, for a component that is already
providing the target around it.
### A mark of your own
```tsx
```
Children replace the built-in cross. Unset, the button draws its own from two bars a quarter
turn apart, so this works in a project that has installed no icon set — the same bargain the
`Checkbox`'s tick and the `Radio`'s dot take.
### As another element
```tsx
…
```
R12, through the base. The missing-label warning does not fire under `asChild`: the caller's
element carries its own label or its own text.
## Sizes
| `size` | Box | Bar | Cross |
| ------ | --- | --- | ----- |
| `xs` | 24 | 12 | ~8 |
| `sm` | 28 | 14 | ~10 |
| `md` | 32 | 16 | ~11 |
| `lg` | 40 | 20 | ~14 |
`md` is the reference implementation's close button measured — a 32-point square — and it is the `Dialog`'s.
The **bar** is twice as long as the cross looks: a bar rotated a quarter turn spans
`length / √2` on each axis. It is kept as a ratio of the box rather than tabulated, so it is
one cross at four sizes instead of four drawings of one — the `Radio`'s dot, again.
The **stroke does not scale**. It is the shared thickness the `Chip`, the `Alert` and the
`Dialog` all draw their cross at; a family of crosses that thicken with their container
reads as four different marks rather than one at four sizes.
The box is small at `xs`, and it is still pressable: the target grows outwards through
`hitSlop`, which costs nothing in layout, rather than the glyph growing.
`radius` is the circle `size` sets — half the box — and overridable, like everywhere else.
## Variants and colour
| `variant` | Disc | Border | Cross |
| ----------- | --------- | -------- | ------------------- |
| `primary` | `accent` | — | `accentForeground` |
| `secondary` | `default` | — | `defaultForeground` |
| `tertiary` | — | `border` | `foreground` |
| `ghost` | — | — | `foreground` |
Four emphasis levels and **no intent**: dismissing is neither a success nor a danger. The
close that carries an intent is the one inside a component that has one — `Alert.Close` in a
danger alert — and it is the alert's recipe that says so, not a variant here.
`secondary` is the neutral grey, the `Checkbox`'s and the `Radio`'s reading of the word
rather than the `Button`'s accent-soft: a close button is grey before it is anything else,
and it sits beside those two more often than beside a `Button`.
`color` is a raw value (R7), and it lands where the variant puts it: the disc on `primary`
and `secondary`, the cross on `tertiary` and `ghost`.
**There is no pressed colour.** The press is the shared `PressableFeedback` treatment,
because the base owns the press state — a cross has to be a different target from the panel
around it — and a root that does not know it is pressed cannot resolve a colour for it.
Every other close button in the library reads the same way.
## Alignment with the reference implementation
**Identical:** the 32-point box at the default size, the circle, the filled default rather
than a bare glyph, the grown touch target, and children replacing the mark.
**Three deltas:**
| Theirs | Ours | Why |
| ------------------------------------------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| A `Button` preset — `tertiary`, `sm`, `isIconOnly`, `hitSlop={12}` | Its own recipe over the shared base | A preset inherits a label slot, a spinner and a `fullWidth`-shaped API for a control that has one mark and no text. |
| `iconProps={{ size, color }}` | `children`, or `color` and `size` | R1 — a prop that styles the inside of another component is the thing the v1 API exists to remove. The glyph is a child. |
| The cross is an imported icon | Two bars, a quarter turn apart | A dismissible screen has to work in a project that has installed no icon set. Children still replace it. |
## Props
Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim
(R14), plus:
| Prop | Type | Default | Notes |
| ------------ | ------------------------------ | ------------- | ----------------------------------------- |
| `variant` | `CloseButtonVariant` | `'secondary'` | Four emphasis levels, no intent |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | The box, and the cross in it |
| `radius` | `RadiusKey` | half the box | A circle, unless you say otherwise |
| `color` | `string` | — | A hex tint — the disc, or the cross |
| `isDisabled` | `boolean` | `false` | Dims it and stops the press |
| `children` | `ReactNode` | — | Replaces the built-in cross |
| `hitSlop` | `Insets \| number` | `8` | From the base — the target, not the glyph |
| `asChild` | `boolean` | `false` | Merge into the single child |
## Accessibility
- `accessibilityRole="button"`, overridable.
- **An `accessibilityLabel` is required**, and its absence warns in development: a cross
says "close" to someone who can see it and nothing at all to someone who cannot. Unlike an
icon-only `Button`, the text beside it names the thing being dismissed rather than the
action, so there is nothing to fall back on.
- The warning does not fire under `asChild`, where the caller's element carries its own
label.
- The touch target is 8 points larger than the box on every side.
# ColorPicker
## Overview
A colour, chosen off a palette. Imports from `@xaui/native/color-picker`. Run the
`color-picker` demo screen to try the real component in light and dark mode.
**Two arrangements, one component.** The **dialog** is a field that opens the palette — the
legacy picker's shape — and the **grid** is that palette on its own, for a settings row or a
theme editor where there is nothing to open. Neither is a mode the root is told about: the
caller writes the slots they want, and a picker with no `Content` in it never mounts a
dialog.
It owns almost nothing. The field is a `DummyField` — the v1 name of the `InputTrigger` the
legacy picker opened its sheet from — and the panel is a `Dialog`, both by construction
rather than by resemblance, so a colour field and a select in one form cannot drift apart.
What this component adds is the chip, the grid and the wiring between them.
## Anatomy
```text
ColorPicker
Label
FieldGroup
FieldGroup.Prefix → Preview
Field
Description / Error
Overlay
Content
Title / Close
Grid
Group
Swatch
```
## Usage
```tsx
import { ColorPicker } from '@xaui/native/color-picker'
import { FieldGroup } from '@xaui/native/field-group'
;
Brand colour
Used across the whole app.
Pick a colour
```
Two layouts. `ramps` is the default — one row per hue, its name in a fixed column beside it.
`mosaic` withholds the names and closes everything up into one continuous block that fills
the width, which is also what makes its cells half again the size:
```tsx
…
```
The grid on its own — no field, no dialog, nothing to open:
```tsx
```
A palette of your own, composed rather than configured:
```tsx
```
Use `value` with `onValueChange` for a controlled colour, or `defaultValue` and let the
picker hold its own. The value is a hex string, and `ColorPicker.Field` writes it in upper
case because a hex is a code rather than a word. Choosing a swatch closes the dialog;
`closeOnSelect={false}` keeps it open, which is also what a grid on a page wants.
`colors` replaces the palette. It defaults to `TAILWIND_PALETTE` — the seventeen Tailwind
hues plus Zinc, at eight steps each — which is exported from the same subpath, so a picker
that wants a subset filters it rather than restating it.
## Props
Generated from TypeScript by `node tooling/component-docs/generate.mjs color-picker`.
The root inherits DummyField's column, styles, states, ref and `asChild` behavior.
### ColorPickerProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| -------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| variant | `ColorPickerVariant \| undefined` | The **field**'s emphasis. It never reaches the grid, which is not a field. |
| size | `Size \| undefined` | The field's height, its padding and the grid's cell. Never width. |
| radius | `RadiusKey \| undefined` | The field's corner, the chip's and the swatch's. The dialog keeps the `Dialog`'s. |
| color | `string \| undefined` | A raw tint (`'#7c3aed'`), never a token (R7). It dresses the field, and it is the ring around the chosen swatch — the legacy picker's `themeColor`, which drove the same two. |
| labelPlacement | `DummyFieldLabelPlacement \| undefined` | Above the box, or lifted into it. |
| layout | `ColorPickerLayout \| undefined` | Named ramps, or one continuous block of colour with no labels at all. |
| value | `string \| undefined` | The chosen colour, as a hex string. Controlled — leave it out and the picker holds its own. |
| defaultValue | `string \| undefined` | |
| onValueChange | `((color: string) => void) \| undefined` | Every choice, including one made in a grid that stays open. |
| isOpen | `boolean \| undefined` | Controlled open state of the dialog. Ignored by a grid with no dialog in it. |
| defaultOpen | `boolean \| undefined` | |
| onOpenChange | `((isOpen: boolean) => void) \| undefined` | |
| colors | `readonly ColorGroup[] \| undefined` | What the grid draws. Defaults to `TAILWIND_PALETTE` — the seventeen hues plus Zinc, at eight steps each. |
| closeOnSelect | `boolean \| undefined` | Whether choosing a colour closes the dialog. On by default: a picker whose only job is one colour has been answered the moment a swatch is pressed. Off for a grid on a page, where there is nothing to close. |
| isInvalid | `boolean \| undefined` | Paints the field's border, label and description in `danger`. |
| isDisabled | `boolean \| undefined` | Dims the field and stops the dialog, the field and every swatch together. |
| children | `ReactNode` | |
| asChild | `boolean \| undefined` | |
### ColorPickerFieldProps
Inherited React Native and composed component props remain available.
### ColorPickerPreviewProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ----- | --------------------- | ----------------------------------------------------------------------------------- |
| color | `string \| undefined` | A colour of your own. Unset, it is the chosen one — which is the point of the chip. |
### ColorPickerContentProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| -------- | ----------- | ----------- |
| children | `ReactNode` | |
### ColorPickerGridProps
Inherited React Native and composed component props remain available.
### ColorPickerGroupProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | `ReactNode` | The hue's name, drawn above the row. A string is wrapped in the label's own `Text` (R3); an element is rendered as it is. Unset, the row carries no label. |
| children | `ReactNode` | The swatches. |
### ColorPickerSwatchProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| color | `string` | The colour this cell offers. |
| accessibilityLabel | `string \| undefined` | What a screen reader reads. Unset, it is the colour's own hex — pass the name a designer would use ("Violet 600"), which is what `ColorPicker.Grid` does. |
| style | `StyleProp \| ((state: PressableStateCallbackType) => StyleProp)` | R9 — `Pressable`'s function form as much as an object or an array. |
## Slots
`Label`, `Description` and `Error` **are** the `DummyField`'s slots, and `Overlay`, `Title`
and `Close` **are** the `Dialog`'s — re-exported rather than wrapped. `Field` is
`DummyField.Field` with the chosen colour filled in as its value and the root's press
attached. `Preview` is the chip, a `View` rather than a control: the field around it is what
opens the dialog, and it wears a dashed edge while nothing is chosen so the field's text does
not shift the first time it is used. `Content` is `Dialog.Content` with the picker's context
put back on the far side of the portal; with no children it is the grid alone. `Grid` is a
`ScrollView` of `Group`s, and `Group` is a wrapping row of `Swatch`es. Each node accepts its
own `style` and style props.
## Variants
The `DummyField`'s four — `primary`, `secondary`, `tertiary`, `ghost` — dress the **field**,
and the four sizes (`xs`, `sm`, `md`, `lg`) drive the field's height, the dialog's inset and
the grid's cell: 24, 28, 32 and 36 points.
**The cell is a basis, not a width.** A row holds the label column, a gap and eight cells
inside whatever the dialog leaves — 326 points on one phone, 311 on the next — and a fixed
cell would have to be sized for the narrowest of them. So the cell is what it takes when
there is room and the ramp **shrinks** rather than wrapping when there is not: 32 points at
`md` on a 390-point screen, 31 on a 375. A ramp that wrapped halfway through itself would
read as two bars rather than one colour getting darker.
The dialog itself is capped at three quarters of the screen and pays less inset than a
`Dialog` of prose, so the page stays visible at both ends and the grid scrolls inside the
panel rather than the panel growing past it.
`color` is a raw tint (R7). It dresses the field and it is the ring around the chosen
swatch — the legacy picker's `themeColor`, which drove the same two. `radius` moves the
field's corner, the chip's and the swatch's together; the dialog keeps the `Dialog`'s own,
because a picker asked for a pill of a trigger did not ask for a pill of a panel.
**`layout` is the grid's own shape, and it is one prop rather than two because the two
halves are the same decision.** Under `ramps` a hue's name sits in a fixed column beside its
row; under `mosaic` there are no names, no gaps anywhere, and the block fills the width. The
names were the only thing holding the rows apart, so dropping them is what lets the block
close up — and the width they were taking goes back to the colour, so a mosaic cell comes
out about half again the size of a ramp's. The hairline moves with the shape it describes:
around each ramp it would double into a two-point line between every pair of rows, so in a
mosaic it is the block's own edge.
**A hue's name sits to the left of its ramp, in a column of fixed width.** Above it,
eighteen captions are eighteen lines of type in a dialog that could have been colour; in the
flow beside it, each ramp would start where its own name ended and the bars would step in
and out of the column by the length of the word. Fixed, they line up, and a name too long
for the column truncates rather than pushing its bar out of line.
**A ramp is one square bar.** Its cells touch, with no gutter and no corner on either end:
a rounded end would put a curve on two of the eight colours and leave the other six square,
and the corner is the one shape a swatch cannot afford, because it is the shape of the
colour itself. The only edge in the grid is a hairline around the bar — one per ramp rather
than one per cell, which would double into a two-point line between every pair of colours —
and it is what keeps a pale ramp visible on a white sheet. `radius` still rounds the bar for
a caller who asks.
The chosen cell draws its ring **over** its colour rather than around it. As a border the
ring would reserve its width at every cell, answer or not, and six points of ground between
two colours is not a ramp; a border that appeared only on the chosen cell, which is what the
legacy picker did, shrank the swatch under the finger at the moment it was pressed.
## Accessibility
The grid is the radio group and each swatch is a radio: a reader chooses one colour out of
the palette, and the rows are how it is arranged rather than what is being asked. Every
swatch carries a label — `ColorPicker.Grid` passes the palette's own name, "Violet 600", and
a hand-written `Swatch` should be given one, because a colour is not text and the hex is
what it falls back to. Mark the prefix `isDecorative` so the chip hands its touches to the
field. `isDisabled` stops the field, the dialog and every swatch together.
## Migration from legacy
| Legacy | v1 |
| ----------------------- | ------------------------------------------------------------------- |
| `onColorChange` | `onValueChange` — the vocabulary every other picker already used |
| `label` / `description` | `ColorPicker.Label` / `ColorPicker.Description`, siblings not props |
| `errorMessage` | `ColorPicker.Error`, mounted by the caller |
| `themeColor` | `color`, a raw value |
| `variant="colored"` | `variant`, the field's four levels |
| `colorGroups` | `colors`, and `TAILWIND_PALETTE` is the default |
| `defaultColorGroups` | `TAILWIND_PALETTE` |
| `sheetTitle` | `ColorPicker.Title`, a slot |
| `sheetStyle` | `style` on `ColorPicker.Content` |
| `swatchSize` | `size`, which moves the field and the cell together |
| `isOpened` | `isOpen` / `defaultOpen` |
| `fullWidth` | gone — a column fills its parent, which is RN's own behaviour |
| a bottom sheet | a `Dialog`, or no overlay at all with `ColorPicker.Grid` |
The legacy palette's `Lime` group held green values and its `Neutral` group held eleven
entries where every other held eight. `TAILWIND_PALETTE` is eighteen ramps of the same
eight steps, so the grid is rectangular and Lime is lime.
# Combobox
A field you type in, over a list you must choose from.
## Import
```tsx
import { Combobox } from '@xaui/native/combobox'
```
## Usage
```tsx
Animal
Chat
Chien
Aucun animal ne correspond
Un seul, et il doit être dans la liste.
```
## Anatomy
| slot | what it is |
| ---------------------- | ---------------------------------------------------- |
| `Combobox` | The column, and the resolved style |
| `Combobox.Label` | What the field is for |
| `Combobox.Trigger` | The field box — a `View`, not a pressable |
| `Combobox.Input` | The field. The query while open, the label while not |
| `Combobox.Indicator` | The chevron, and the way in without typing |
| `Combobox.Overlay` | The backdrop. Optional |
| `Combobox.Content` | The panel |
| `Combobox.Item` | One row |
| `Combobox.Empty` | What the panel says when nothing matches |
| `Combobox.Description` | The hint under the field |
| `Combobox.Error` | What is wrong with the choice |
## It is the `Autocomplete` with the search moved into the trigger
In an [`Autocomplete`](../autocomplete/autocomplete.md) the control shows the **chosen row**
and you type in a box inside the panel. Here the field **is** the trigger: what you have
typed is what you see, on the line where a `TextField` would be, rather than a button that
opens a search.
That is the ARIA combobox, and it is the shape a form wants. The autocomplete's shape is
what a filter bar or a "jump to" wants — the control keeps saying what is currently chosen
while you look for something else.
## The list is closed
What is typed **narrows the rows and never becomes the value**. The query goes with the
panel, so closing without choosing puts the chosen row's label back in the field.
A combobox that kept the half-typed word would be a text field with a dropdown attached —
which is a real control, and a different one. Here the field can only ever end up holding
something that is in the list.
```tsx
value={isOpen ? query : labelFor(value)}
```
is the whole of it, and it is why there is no `onInputChange` separate from
`onQueryChange`: there is one string being typed, and it is the query.
## What it shares, and why
**The root is the `Autocomplete`'s**, wrapped rather than aliased. A value, an open panel
and a query narrowing the list is all either component holds, and a second copy of that
would be a second thing to keep in step.
**The panel, the rows and the empty line are the `Autocomplete`'s own objects** —
`Combobox.Content` **is** `Autocomplete.Content`. Two reasons, and the second is the
load-bearing one:
- A row is a row whichever field opened it. A second set would drift, and it would show as
a combobox and an autocomplete in one form with panels half a shade apart.
- `Autocomplete.Content` tells its children apart **by identity** (`type === Item`), not by
name. A `Combobox.Item` that were a different component would be sorted into "not a row",
would never be filtered and would never register its label.
**The label and the help lines come from the same place**, and for the same reason: the root
_is_ the `Autocomplete`'s, so the column it renders is already the `TextField`'s, and a
combobox that labelled its field differently from the text field beside it would be exactly
the drift this sharing exists to prevent. `Combobox.Label`, `Combobox.Description` and
`Combobox.Error` are the autocomplete's slots under this component's name.
What this component owns is the three slots that make the field the control: the trigger,
the input and the chevron. `useCombobox()` is `useAutocomplete()`, for the same reason
(R10).
## Three slots, and why each is what it is
**`Combobox.Trigger` is a `View`, not a `Pressable`.** The thing you press is the input
inside it, and a pressable wrapper around a text field is a second target laid over the one
that already takes the tap — same pixels, different effect. The autocomplete's trigger is a
pressable because it holds no field; this one holds nothing else.
It carries `accessibilityRole="combobox"` and measures itself in window coordinates on every
open, because that rectangle is what the panel positions against and `onLayout` fires once.
**`Combobox.Input` fills the box**, so the box is one target and it is the one that raises
the keyboard. It takes the trigger's own text style: it stands where the chosen value stands
in a `Select`, and a query that read smaller than the answer it replaces would make the
control change size as you typed.
Typing opens the panel and focusing opens it — `opensOnChange={false}` and
`opensOnFocus={false}` turn either off, for a control that should only open from the chevron.
**`Combobox.Indicator` is a control**, where the autocomplete's is a decoration. That
component's trigger is itself pressable, so its chevron only has to point; this one sits
inside a field that takes the tap and raises a keyboard, so the way into the list _without_
typing has to be the chevron itself. It presses with a `hitSlop`, and it takes an
`accessibilityLabel` because a chevron is not text.
## Variants, sizes and colour
All of them are the `Select`'s, resolved through `selectRecipe` exactly as the
`Autocomplete`'s are: four field levels, three sizes, `radius`, `color`, `isInvalid`,
`isDisabled`. A second table would be a third one to keep in step.
## Alignment with the reference implementation
Theirs is `ComboBox` with `InputGroup` · `Value` · `Trigger` · `Popover`.
| Theirs | Ours | Why |
| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `ComboBox.InputGroup` | `Combobox.Trigger` | The box that holds the field is the trigger — it is what the panel anchors to, and naming it twice would hide that. |
| `ComboBox.Value` | `Combobox.Input` | One node. The value and the query are the same string in the same box; two slots for it is two things that can disagree. |
| `selectionMode="multiple"` | — | One value, as everywhere else here. Several is a `TagGroup` beside a field, which says what it holds without a popover open. |
## Props
The root's props are the `Autocomplete`'s, to the prop: `variant`, `size`, `radius`,
`color`, `value` / `defaultValue` / `onValueChange`, `isOpen` / `defaultOpen` /
`onOpenChange`, `query` / `defaultQuery` / `onQueryChange`, `isDisabled`, `isInvalid`,
`asChild`. The root is the **column** — `ref`, `style` and R14's style props on it dress
that column, not the field box.
### `Combobox.Input`
Everything `TextInput` accepts, plus the `TextStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| --------------- | --------- | ------- | ---------------------- |
| `opensOnChange` | `boolean` | `true` | Typing opens the panel |
| `opensOnFocus` | `boolean` | `true` | Focusing opens it |
### `Combobox.Trigger`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus `asChild`.
### `Combobox.Indicator`
`as` for the glyph, and `accessibilityLabel`.
## Accessibility
- `accessibilityRole="combobox"` on the trigger, with `accessibilityState.expanded`
following the panel — on the box rather than on the input, because it is the box that
expands and a screen reader reads the field inside it as the field it is.
- The chevron is a `button` and needs an `accessibilityLabel`: it is not text.
- `aria-invalid` follows `isInvalid`, on the input as well as on the box.
- The input points at `Combobox.Label` and `Combobox.Description` through `aria-labelledby`
and `aria-describedby`, so what is announced is what the field was asking for rather than
only what has been typed into it. Mounting the two slots is all it takes; your own
`aria-labelledby` still wins.
- The panel keeps taps through the keyboard (`keyboardShouldPersistTaps`), so choosing a row
while the keyboard is up takes one tap rather than two.
# DatePicker
A field that opens a month.
## Import
```tsx
import { DatePicker } from '@xaui/native/date-picker'
```
## Usage
```tsx
Date de naissance
{error ? (
{error}
) : (
jj/mm/aaaa
)}
```
`DatePicker.Field` is the trigger in one tag — `DatePicker.Trigger` around
`DatePicker.Value` and `DatePicker.Indicator`. Open it up for finer control:
```tsx
```
## Anatomy
| slot | what it is |
| ------------------------ | -------------------------------------------------- |
| `DatePicker` | The **column** — state, resolved style, one `gap` |
| `DatePicker.Label` | What the field is for. `danger` on `isInvalid` |
| `DatePicker.Field` | The trigger in one tag — value and glyph inside it |
| `DatePicker.Trigger` | The control — the field the user sees |
| `DatePicker.Value` | The chosen day, or the placeholder |
| `DatePicker.Indicator` | The calendar glyph on the field |
| `DatePicker.Description` | The hint under the field |
| `DatePicker.Error` | What is wrong, in `danger` |
| `DatePicker.Overlay` | The backdrop. Optional |
| `DatePicker.Content` | The panel |
| `DatePicker.Calendar` | The month, bound to the picker |
**The root is the `TextField`'s column**, token for token: the same `root` gap, the same
label and help styles, resolved through `textFieldRecipe`. A date field and a text field
stacked in one form line up and read as one control — the same reason the trigger is a
`Select`'s and the grid is a `Calendar`.
**`isInvalid` paints the colours; the caller mounts the message.** It turns the border, the
label and the description `danger` — it does not add or remove `DatePicker.Error`, because a
slot that silently renders nothing is one you cannot debug. You write the condition and see
it, exactly as on a `TextField`. No auto-wrap either (R3): a string child of the root is not
a label or a hint in any way the component could guess.
## It owns almost nothing
The trigger **is** a `Select`'s trigger, the panel **is** a `Select`'s panel, and the grid
**is** a [`Calendar`](../calendar/calendar.md) — all three by construction rather than by
resemblance. A select and a date field in one form cannot drift apart, and a calendar in a
picker cannot differ from one on a page.
What this component adds is the wiring, and it is worth naming because each piece is a place
two things could otherwise disagree:
- the chosen day read into the field through `Intl`,
- a panel that closes when a day is pressed,
- **one** set of bounds, which the field, the grid and the chevrons all read.
`DatePicker.Calendar` takes the `Calendar`'s props _minus_ the ones the picker already owns —
value, bounds, locale, first day, variant, disabled — because two sources for one of them
would be two answers to one question.
## The panel matches the field, with the grid as its floor
`width` defaults to **`trigger`** with a `minWidth` of **seven cells** — the `Calendar`'s
own `7 × cell` at the picker's `size`.
So a narrow field opens a panel exactly seven cells wide, and a wide field opens one as wide
as itself: the panel belongs to the field, and a month grid squeezed under a narrow one
would crush its columns or clip a week. `DatePicker.Calendar` fills the panel (`width: 100%`)
so a wide one spreads its cells rather than leaving a gap beside them.
Override it like any anchored surface: `width="content-fit"` for the compact seven-cell
panel whatever the field, `width={320}` for a fixed one, or your own `minWidth`.
`minWidth` is `maxWidth`'s mirror — a floor under the resolved width, on `DatePicker.Content`
and `Select.Content`, and the screen edge still overrides it.
## The field's level is not the calendar's
```tsx
```
A quiet trigger over an emphatic chosen day is the ordinary case, so `variant` dresses the
field and `calendarVariant` dresses the grid. `color` reaches both — the field's fill and the
chosen day's disc are the same brand.
## The month on screen is not bound
The picker owns the **value**; the calendar owns the **month it is showing**. Opening the
panel a second time after paging leaves you where you were, and choosing a day in another
month still works — paging is not choosing, which `calendar.md` argues at length.
## A year and a month, from the panel
`DatePicker.Calendar` is a `Calendar`, so its year → month → day walk composes here exactly
as it does on a page: pass the children, hold `view`, and mount `Calendar.YearPicker` /
`Calendar.MonthPicker` in the grid's place.
```tsx
const [view, setView] = useState('grid')
{ if (!open) setView('grid') }}>
{/* trigger, overlay … */}
setView(v => (v === 'grid' ? 'year' : 'grid'))}>
{view === 'year' ? (
) : view === 'month' ? (
) : (
<>
>
)}
```
`view` is the `Calendar`'s, not the picker's — resetting it to `'grid'` on close is what
makes the panel open on the month every time.
## `closeOnSelect`
On by default: a picker whose only job is one date has been answered the moment a day is
pressed, and a panel that stayed open would need a second control to say so.
`closeOnSelect={false}` for a picker inside a form that confirms — where the caller writes
their own footer under the grid through `DatePicker.Calendar`'s children.
## `formatOptions`
```tsx
```
Anything `Intl.DateTimeFormat` takes. `dateStyle: 'medium'` by default — "6 sept. 2026" —
because a field is a line on a form and the long form is a sentence.
It changes **how the field reads the day and nothing else**: the value is a `Date` at
midnight local time either way. A Hermes build with no ICU falls back to the ISO day, which
is unambiguous everywhere even where it is nobody's habit.
## Props
### `DatePicker`
| Prop | Type | Default | Notes |
| ----------------- | ---------------------------- | ------------ | ---------------------------------- |
| `variant` | `DatePickerVariant` | `'primary'` | The field's level |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The field, and the calendar's cell |
| `radius` | `RadiusKey` | — | The field's corner |
| `color` | `string` | — | The field and the chosen day |
| `calendarVariant` | `CalendarVariant` | — | The grid's level |
| `value` | `Date` | — | Controlled |
| `defaultValue` | `Date` | — | |
| `onValueChange` | `(value: Date) => void` | — | Midnight, local time |
| `isOpen` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | |
| `onOpenChange` | `(isOpen: boolean) => void` | — | |
| `minValue` | `Date` | — | Compared by day |
| `maxValue` | `Date` | — | |
| `firstDayOfWeek` | `0`–`6` | the locale's | |
| `locale` | `string` | the device's | |
| `formatOptions` | `Intl.DateTimeFormatOptions` | `medium` | How the field reads it |
| `closeOnSelect` | `boolean` | `true` | |
| `isDisabled` | `boolean` | `false` | |
| `isInvalid` | `boolean` | `false` | Paints the colours, mounts nothing |
| `asChild` | `boolean` | `false` | The caller's element is the column |
The root **is the column** (a `View`, or the caller's element with `asChild`), so `ref`,
`style` and R14's style props are the column's. `DatePicker.Trigger` keeps its own — it is
the node the panel measures, and everything `Pressable` takes is written there.
### `DatePicker.Content`
The `Select`'s placement props — `placement`, `align`, `width`, `minWidth`, `offset`,
`alignOffset`, `avoidCollisions`, `insets` — with `width` defaulting to `trigger` and
`minWidth` to the `Calendar`'s seven-cell width.
## Accessibility
- The trigger is a `button`, not a `combobox`: there is nothing to type into, and a month
grid is not a list of options a screen reader can walk from the field.
`accessibilityState.expanded` follows the panel; `aria-invalid` follows `isInvalid`.
- The backdrop announces nothing — it is the absence of the panel, and "button" over the
whole screen is worse than silence.
- Every day inside is labelled with its full date by the `Calendar`.
# DateRangePicker
A field that opens a month, and takes two days from it.
## Import
```tsx
import { DateRangePicker } from '@xaui/native/date-range-picker'
```
## Usage
```tsx
```
## It owns almost nothing
The trigger **is** a `Select`'s trigger and the month **is** a `RangeCalendar`, which is
itself a `Calendar`. What this adds is the wiring: two ends read into the field through
`Intl`, and a sheet that closes on the second one.
Every rule the `RangeCalendar` has — the third press, the backwards range, the one-day range
— is this component's too, because it is that component.
**A sheet rather than an anchored panel**, for the `TimePicker`'s reason: a month is three
hundred points wide, which beside a field on a phone is the screen.
## The first choice never closes the sheet
A period is **two decisions**, and a sheet that shut after the first would make the second one
a second opening. `closeOnSelect` governs the second only.
## A start with no end reads as itself
Not as `"start – "`. A dash with nothing after it says the field is broken, where a lone date
says it is half answered — which is exactly what it is between the two presses.
The separator is an **en dash** with a space either side, not a hyphen: a hyphen is already
the date separator in half the locales this field serves.
## `variant` dresses the field, `calendarVariant` dresses the month
A `ghost` field over a `primary` month is the ordinary case — the trigger is quiet on the form
and the chosen period is not.
`minValue`, `maxValue`, `firstDayOfWeek` and `locale` pass straight through.
`formatOptions` is `Intl.DateTimeFormatOptions` and reads **each end** with it.
The root **renders no node**: `ref`, `style` and the a11y props live on
`DateRangePicker.Trigger`.
## See also
- **`DateRangeField`** — a period _typed_ rather than chosen.
- **`RangeCalendar`** — the month on its own.
- **`DatePicker`** — one day rather than two.
# DateTimePicker
A field that opens a month, and then a clock.
## Import
```tsx
import { DateTimePicker } from '@xaui/native/date-time-picker'
```
## Usage
```tsx
```
## It owns nothing at all
| the part | what it actually is |
| ------------- | ---------------------- |
| the field | a `Select`'s trigger |
| the two steps | a `Tabs` |
| the month | a `Calendar` |
| the dial | a `TimePicker` |
| the mark | `TimePicker.Indicator` |
Four components rendered **as themselves** rather than four tables restated. It has no recipe
of its own — the only style it touches is the field's, and that one belongs to the `Select`.
That is also why `TimePicker.Indicator` reads `IconContext` rather than its own picker's
context: a glyph another field can render is one glyph to keep right, not two.
## Two steps rather than two fields
A moment is one value, so it is one control. A calendar and a clock will not fit on a phone at
the same time, which is why the two take turns.
**Choosing a day moves to the clock** — the `TimePicker`'s hours handing over to its minutes,
one level up — and choosing the minutes closes the sheet. The common path is one gesture.
**The steps are a tab bar and not a stepper**, because either half can be changed at any time:
a reader who set the day, then the time, then wants the day again presses "date" rather than
starting over.
Each tab **reads the half it stands for** once there is one — the date under "date", the time
under "time" — so the sheet says what has been decided without a header of its own.
The sheet reopens on the date, always: one that reopened on the clock would hide the month
from a reader who came to change the day.
## Each half keeps the other
A day chosen after a time keeps the time. A time chosen after a day keeps the day. The value
is **one moment being narrowed**, not two values being collected.
With nothing chosen yet, the first day lands at midnight — which is what the dial shows when
it opens.
## The pieces are composable
`DateTimePicker.Sheet` with no children assembles `Steps`, `Calendar` and `Clock`. Written
out, the whole thing goes on a page with no sheet at all, which is also how the demo screen
makes both steps visible without opening anything:
```tsx
{step === 'date' ? : }
```
`DateTimePicker.Clock` renders a real `TimePicker` with `closeOnSelect={false}`: the minutes
close the sheet the _trigger_ opened, and that inner picker has no sheet of its own.
## Props
`hourCycle`, `minuteStep`, `minValue`, `maxValue` and `locale` pass straight through to the
two components. `formatOptions` is `Intl.DateTimeFormatOptions` and defaults to
`{ dateStyle: 'medium', timeStyle: 'short' }`.
`step` / `defaultStep` / `onStepChange` drive which half is on screen, controlled or not.
The root **renders no node**: `ref`, `style` and the a11y props live on
`DateTimePicker.Trigger`.
## See also
- **`DateTimeField`** — a moment _typed_ rather than chosen.
- **`DatePicker`** and **`TimePicker`** — either half on its own.
# Dialog
A question the page has to be answered before it goes on.
## Import
```tsx
import { Dialog } from '@xaui/native/dialog'
```
## Anatomy
```tsx
…
…
…
…
```
- **`Dialog`** — state and resolved style. **It renders no node.**
- **`Dialog.Trigger`** — what asks the question.
- **`Dialog.Overlay`** — the backdrop. It dims, and closes on a press.
- **`Dialog.Content`** — the panel, centred and inset from the screen's edges.
- **`Dialog.Title`** / **`Dialog.Description`** — the question and what it costs.
- **`Dialog.Close`** — the way out. A cross when it is empty, whatever you put in it
otherwise.
## Usage
### Basic
```tsx
Supprimer
Supprimer ce document ?
Définitif, sans corbeille.
Supprimer
```
### One that must be answered
```tsx
```
A press outside does nothing, so the only way out is through a `Dialog.Close`.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `Dialog`
| prop | type | default | description |
| -------------- | --------------------------- | ------- | ---------------------------- |
| `radius` | `RadiusKey` | — | Overrides the panel's corner |
| `isOpen` | `boolean` | — | Controlled |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close |
| `isDisabled` | `boolean` | `false` | Stops the trigger |
No `variant`. A dialog is the theme's floating surface: the question it asks is in its
words, not in its fill.
### `Dialog.Overlay`
`isDismissable` (default `true`), plus `ViewProps` and `ViewStyle` as props.
### `Dialog.Trigger`
Everything `Pressable` takes, plus `ViewStyle` as props and `asChild`. It paints nothing of
its own — a dialog is asked by a button, and giving the trigger a surface would put a
second box around one.
### `Dialog.Close`
Everything the shared `CloseButtonBase` takes, minus `name`, `baseStyle` and `glyphStyle`,
which the dialog supplies. Two shapes, and the empty one is the default:
```tsx
{/* a 32pt disc, muted cross */}
{/* the answer, in words */}
Supprimer
```
**Empty, it draws a cross** — the shared button's, from two rotated bars rather than an
icon, so the corner affordance works in a project that has installed no icon set.
Every measurement is the reference implementation's, read off their CSS. `close-button.css` sets
`height: calc(var(--spacing) * 8)` on a spacing base of 4, so the box is **32 points**, and
`isIconOnly` gives it `aspect-ratio: 1` and a radius past half its height — a circle. It is
**filled**, not a bare glyph: their `CloseButton` is a `tertiary` button, and their
`tertiary` is `background-color: var(--color-default)`, the same token our `secondary`
names. The cross itself is `muted`. A cross floating on the panel with nothing under it
reads as decoration; the disc is what makes it a target.
**It places itself nowhere.** `alignSelf="flex-end"` above the title, or `position`,
`top` and `end` over content that has room for it — a title with space beside it and one
without want different answers, and that is layout, which is the caller's (R4). This is
what the reference implementation does too: their own examples use `absolute top-3 inset-e-2.5`, `items-end` and
`self-end me-4` in three different dialogs. The placement is in the example, not in the
component.
Under `asChild` the box is not applied, and the missing-label warning does not fire: the
caller's element has a height and a shape of its own, and its text is the label.
## How it differs from the `Popover`
Same portal, same context re-provision, same overlay keyframes. What it drops is everything
that needs an anchor: the measuring pass, the host origin, the collision flip. A centred box
has nothing to be measured against.
What it adds is two things:
**The backdrop dims.** A popover is an aside you read the page around; a dialog is a
question, and the page behind it is not available until it is answered.
**It grows from its own centre**, 200 ms from `scale: 0.94`, rather than out of a trigger.
A popover's entrance is offset towards the thing that opened it so the motion points back
at it; a dialog belongs to the screen rather than to a control, so the absence of a
direction is the message.
## Two layers on the content
A centred box cannot also be the thing that centres it. An outer layer fills the portal and
does the centring; the panel is the box. The outer one takes **no touches**, so a press that
misses the panel reaches the overlay under it and closes the dialog.
## Accessibility
The panel is `accessibilityViewIsModal`, so a screen reader stops at the dialog rather than
reading the page behind it — the spoken half of what the backdrop says visually. The title
is a `header`. The overlay announces nothing.
# Divider
The line between two things.
## Import
```tsx
import { Divider } from '@xaui/native/divider'
```
## Anatomy
```tsx
```
**One node and no slots.** A rule is a filled box one point thick; there is nothing inside
it. A divider with a word in the middle of it is a `Row` holding two of these and a
`Typography` — the composition the library already has. A `Divider.Label` would put a
layout inside a line.
## Usage
Between rows, in a column:
```tsx
…
…
```
Between two words, in a row:
```tsx
Brouillon
Il y a deux minutes
```
A section break, rather than a row break:
```tsx
```
With a word across it — two dividers and a label, not a prop:
```tsx
ou
```
## Props
| Prop | Type | Default |
| ------------- | ------------------------------ | -------------- |
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'xs'` |
| `color` | `string` — a raw hex tint (R7) | — |
| `style` | `StyleProp` | — |
Plus every `ViewStyle` key as a prop (R14) and every `View` prop.
### No `variant`, and it is the only component in the core without one
A variant is the design system's **vocabulary** (§1 bis) — a name that means the same thing
everywhere it appears. On a rule there is nothing for such a name to describe: no fill
against a foreground, no border against a surface, no intent to report.
It used to have three, naming the three separator tokens. That is a shade of grey wearing a
word. `size` says how heavy the rule is, `color` says what colour it is in React Native's
own values, and between them there is nothing a third name would add.
The theme still decides the default: the rule paints `separator`, and `color` is the way
past it.
### `size` is the thickness
`xs` is the reference implementation's `thin` — one device pixel, `StyleSheet.hairlineWidth` — and `lg` is their
`thick`, six points, with the two steps our ladder puts between them.
It is `size` and not `thickness` because that is the vocabulary word (§1 bis), and it is
consistent with the rest of the library rather than in spite of it: "`size` drives height,
never width" is exactly what this does, on the axis `orientation` names.
**`xs` is the default, where every other component defaults to `md`.** It is the one place
in the library that departs from that, because a rule you notice is a rule that is too
thick.
### The other axis comes from the parent
`alignSelf: 'stretch'` is the whole mechanism. In a `Column` the cross axis is horizontal,
so a stretched child is full width; in a `Row` it is vertical, so the same word makes a
vertical rule full height. On the axis the thickness fixes, `stretch` is ignored.
So there is no `width` prop and no `height` prop to remember, and a **horizontal divider
written inside a `Row` collapses on purpose** rather than guessing what you meant. Give it
a width if that is really what you want.
### `asChild`
R12. What it is for here is the animated rule: an `Animated.View` that collapses a section
takes the thickness and the ink from the recipe and the height from a shared value.
```tsx
```
## Accessibility
`accessibilityElementsHidden` and `importantForAccessibility="no"` are set: a rule is
furniture. A screen reader reads the two groups it sits between, not the line — and React
Native has no `separator` role to announce, so leaving it visible would add a stop to every
list. Both stay overridable (R9).
## Migration
| Legacy | v1 |
| ----------------------------- | ------------------------------------ |
| ` ` | ` ` |
| ` ` | ` ` |
| ` ` | ` ` |
| ` ` | ` ` |
# DummyField
A field container styled identically to a text input (`TextField`), but non-editable and
pressable. Designed as a trigger for pickers, modals, bottom sheets, menus, or file selection —
or for presenting a read-only field value that can be tapped.
Renamed from legacy `InputTrigger` to match the `*Field` vocabulary (`TextField`, `MaskField`,
`NumberField`, `TimeField`) and avoid clashing with the `*.Trigger` slot vocabulary of overlay
compounds (`Select.Trigger`, `Popover.Trigger`, `Menu.Trigger`).
## Import
```tsx
import { DummyField } from '@xaui/native/dummy-field'
```
## Usage
### Basic trigger
```tsx
Country
{selectedCountry}
Where you reside.
```
### Composed with Indicator
```tsx
Appointment date
{appointmentDate}
```
### Inside label
```tsx
Category
{category}
```
### Inside a `FieldGroup`
```tsx
Attachment
```
## Anatomy
- **The root is the column (`DummyFieldRoot`), not the field.** It renders the outer `View` that stacks
`DummyField.Label`, `DummyField.Field`, `DummyField.Description`, and `DummyField.Error`.
- **`DummyField.Field` is the pressable control.** It renders a `PressableFeedback`, forwards refs,
applies pressed styling, and receives touch interactions.
- **`DummyField.Value` is the content text.** It renders the selected text or the placeholder in
`fieldPlaceholder` colour when empty.
- **`DummyField.Indicator` is the affordance glyph.** Defaults to `ChevronDownIcon` and inherits
glyph size and color from the resolved recipe context.
## Props
### `DummyField` (Root)
| Prop | Type | Default | Description |
| ---------------- | --------------------------------------------------- | ------------- | ------------------------------------------------ |
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `'secondary'` | Visual emphasis of the field box |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Height, horizontal padding, type size, and gaps |
| `radius` | `RadiusKey` | `'field'` | Corner radius override |
| `labelPlacement` | `'outside' \| 'inside'` | `'outside'` | Outside in column flow or lifted inside the box |
| `color` | `string` | — | Raw tint override |
| `isInvalid` | `boolean` | `false` | Turns border, label, and description to `danger` |
| `isDisabled` | `boolean` | `false` | Reduces opacity and disables press interactions |
| `onPress` | `(event: GestureResponderEvent) => void` | — | Field press handler passed through context |
| `asChild` | `boolean` | `false` | Merge props and refs into single child element |
### `DummyField.Field`
| Prop | Type | Default | Description |
| ------------- | ------------ | ------- | ---------------------------------------------- |
| `placeholder` | `string` | — | Text shown when no value or child is provided |
| `value` | `ReactNode` | — | Value to display when child is not passed |
| `onPress` | `() => void` | — | Called on press; composed with root `onPress` |
| `asChild` | `boolean` | `false` | Merge props and refs into single child element |
### `DummyField.Value`
| Prop | Type | Default | Description |
| --------------- | -------- | ------- | ---------------------------------------------- |
| `placeholder` | `string` | — | Text shown when empty |
| `numberOfLines` | `number` | `1` | Maximum number of text lines before truncating |
### `DummyField.Indicator`
| Prop | Type | Default | Description |
| ---- | ----------------------------------- | ----------------- | -------------------------- |
| `as` | `ComponentType` | `ChevronDownIcon` | Custom indicator component |
# EmptyState
What is on the screen when there is nothing on the screen.
## Import
```tsx
import { EmptyState } from '@xaui/native/empty-state'
```
## Usage
```tsx
Aucun message
Ce qu’on vous envoie arrivera ici.
Écrire un message
```
## The header and the content are two roots
The gap between a mark, a title and a sentence is a **different gap** from the one between
that block and the buttons under it, and R4 puts layout on a root — so two gaps need two
roots.
It is also what lets an empty state with nothing to do about it leave `EmptyState.Content`
out entirely, rather than render an empty row with a gap above it.
## Variants
| variant | what it draws |
| ---------- | -------------------------- |
| `plain` | nothing |
| `surface` | the `surface` fill |
| `outlined` | a **dashed** edge, no fill |
**`plain` is the default**, and it is the right one more often than not: most empty states
fill a screen, and a screen already has a ground.
**`outlined` is the one that is not a fill.** A dashed edge round the space the content would
occupy says "this is a container, and it is empty"; a solid one would say "this is a panel".
It is what a drop target and an empty column want.
There is no `success`, no `warning` and no `danger`: an empty state reports nothing — it is
what is left when there is nothing to report.
## The media
`EmptyState.Media` takes `variant="icon"` to put its child in a circle of muted surface.
**That is not decoration.** A 24-point mark alone in the middle of a screen reads as an image
that failed to load; the circle gives it a size, and the fill says it is a mark rather than a
photograph.
`plain` is the default, because an avatar or an illustration brings its own shape and a
circle behind it would be a second one.
Either way it publishes the glyph's size and colour through `IconContext`, so an `Icon`
inside takes both without being told — and `useEmptyState().icon` is the same two values for
a glyph you draw yourself.
## The tint
`color` is a raw value (R7) and lands on the icon's circle, the mark on it, and the outline.
**Never on the words.** An empty state's text is the page's own ink, and tinting the sentence
would make the quietest thing on the screen the loudest.
The mark takes the tint's **contrast colour**, not the tint — `fgSelected` names a
`*Foreground` token, which is what tells `resolveTint` to answer with the readable colour on
the tint rather than with the tint itself. Without it a grey mark sits on a saturated circle.
## Size
`size` moves the media, the gaps and the type — **never a height**. An empty state is as tall
as what is in it; how tall the space it fills is, is the layout's business.
## Accessibility
`EmptyState.Title` is an `accessibilityRole="header"`, which is what a screen reader jumps
between — so a screen whose only content is an empty state is still navigable.
## See also
- **`Surface`** — the ground, when all you need is a fill.
- **`Skeleton`** — for content that is _coming_, rather than absent.
# Fab
The one thing to do on a screen, floating over the thing it does it to.
## Import
```tsx
import { Fab, FabDiscovery, FabMenu } from '@xaui/native/fab'
```
## Usage
```tsx
Nouveau
```
A bare string is a label (R3), so `Composer ` works.
## It is not a `Button`, and it shares the `Button`'s table
The seven intents are the same seven — the thing a FAB floats over is as likely to be a
delete as a compose — so the variant table is copied token for token and a compose button
and a compose FAB are the same green.
The **recipe** is not shared, and that is deliberate. A button is a row of text with padding;
this is a fixed square that carries a shadow **at rest**, because floating over content is
what it does. One recipe would have meant a `size` axis meaning a height on one and a side on
the other, and a shadow in `base` that a button never wants.
## `isExtended` is a prop
Not "there is a `Fab.Label` in here". The root's recipe resolves **before** its children do:
the shape has to be known when the box is measured, and the box is measured before the label
inside it exists.
An extended FAB keeps the size's height and gives up its width, so a round one and an
extended one sit on the same line at the same height. It **hugs its content** rather than
filling its parent — that is where it parts company with a `Button`, whose stretching in a
column is RN's own behaviour and the right one. A full-width FAB is a button.
## Size
Three steps on the square's side: **40, 56 and 72** points. `size` is the
side of the square, or an extended one's height — never its width.
| size | side | glyph |
| ---- | ---- | ----- |
| `sm` | 40 | 18 |
| `md` | 56 | 24 |
| `lg` | 72 | 28 |
## `placement`
Unset it sits in the flow, which is what a FAB inside a card or a toolbar wants.
The three others pin it to the bottom of its nearest positioned ancestor — `bottom-start`,
`bottom-center`, `bottom-end`. **`start` and `end`, never left and right** (R13), so a
right-to-left layout moves it with no second branch. `offset` is how far in, in points, and
defaults to 16.
`bottom-center` is `alignSelf`, not `start: '50%'`: the latter would centre the box's _edge_
rather than the box.
## `isLoading`
Swaps the mark for a ring and stops the press. The label stays, so
`Envoi… ` reads as one control changing
state rather than as a control being replaced.
The ring is the recipe's own — the `Button.Spinner`'s argument — so it follows the FAB's size
and its variant with nothing to pass. Composing ` ` yourself is how you put it
after the label instead of before it.
## `FabMenu`
A FAB that opens the two or three things it could have been.
```tsx
Nouveau message
Nouveau libellé
Nouveau dossier
```
It is a compound of its own, exported from the FAB entry point. Its trigger renders a `Fab`,
so the two primitives stay available from one import.
**The root renders no node.** It holds the disclosure, the anchor and the styles the slots
read. `FabMenu.Trigger` is the FAB and keeps its own `ref`; `FabMenu.Overlay` and
`FabMenu.Content` portal out, so they add nothing where they are written. `isOpen`,
`defaultOpen` and `onOpenChange` are the root's, so a menu is controlled or not exactly as a
`Select` is.
`FabMenu.Trigger` takes everything a `Fab` takes **except `size`**, which is the root's
because the pills read it too — a menu whose actions were sized apart from the FAB they come
out of would read as two controls. `color` on the root paints the pills; `color` on the
trigger paints the FAB, and they are deliberately two props: a menu whose actions were the
same colour as the button they came out of would read as one shape that had grown.
A bare string child of `FabMenu.Item` is wrapped in a `FabMenu.Label` (R3). Write the
label by hand when there is a `FabMenu.Icon` beside it.
### The FAB does not move when the menu opens
That is the whole reason this exists rather than the legacy `FabMenu`. That component
re-rendered its FAB **inside the portal**, at the portal's own bottom-end inset, so a FAB
sitting anywhere else jumped across the screen at the moment it was pressed.
Here the trigger is never re-parented. It measures itself with `useAnchorRef` — on layout
and again on every open, so a FAB inside a `ScrollView` reports where it actually is — and
the actions are positioned against that rectangle by `useAnchoredPosition`. That is the
`Select`'s and the `Menu`'s machinery, unchanged.
### It is not a `Menu`
A menu is one surface with rows inside it. These are separate pills with air between them,
and the difference is not decoration: a panel dropping out of a field is that field's list
of answers and belongs to its edge, while a FAB floats over the page — there is no edge for
its actions to belong to, so each one carries its own.
Three consequences:
- **The column has no surface.** No background, no border, no shadow; the pills are the
surface — `theme.colors.overlay`, which is white in light mode and the theme's raised
surface in dark. `content-fit` is the only width that makes sense on it, because a
`trigger` width would crush three words into the FAB's diameter.
- **The pill wears the `surface` shadow, not the `overlay` one** a `Menu` panel wears.
Three pills twelve points apart, each carrying a sixteen-point blur, pool into one grey
smudge behind the lot of them.
- **The press feedback is the shared one.** A `Menu` row darkens because the scale treatment
on a full-width strip reads as the panel twitching; a pill is a small floating button —
the same object the FAB above it is — and `PressableFeedback`'s treatment is right on it.
**`FabMenu.Overlay` dims**, where a `Menu`'s and a `Select`'s do not. Those drop out of a
field and leave the page alone, because the page is still the context for the answer they
are asking for; a FAB floats over everything and its actions replace the screen's one thing
to do with three. That is a `Dialog`'s situation, and it takes the `Dialog`'s `backdrop`
token — which is also what puts the pills on a dimmed ground, where a white pill reads as
white. `backgroundColor="transparent"` on the slot takes the dimming back off, and omitting
the slot leaves no backdrop and nothing to press outside.
`top` and `end` are the defaults, because that is where a FAB is. `avoidCollisions` still
flips the column below the trigger for a FAB at the top of a screen.
### Accessibility
The trigger carries `accessibilityState.expanded`. The column carries
`accessibilityRole="menu"` — a `Menu`'s panel needs no role because its surface is the
container a screen reader stops at, and these pills have no surface between them, so without
it nothing says a set of actions has appeared. Each action is a `menuitem`.
`isDisabled` on the root stops the trigger and every action with it; on one action it stops
that one. Either way the pill dims once, never twice.
## `FabDiscovery`
The coach mark that says what a FAB is for.
```tsx
Composez d’où vous voulez
Ce bouton suit chaque écran de la boîte de réception.
Compris
```
**It is opened by the app, not by the FAB.** A discovery is shown because this reader has
not seen the feature — a question only the app can answer — so `isOpen` is controlled far
more often than not, and pressing the target does what it always did. `FabDiscovery.Action`
takes the mark down after the caller's `onPress` has run; so does a press on the overlay.
`color` is a raw tint for the disc and everything on it. Unset it is the theme's `accent`,
which is what a coach mark means: the one thing on this screen worth pointing at. `scale`
sets the disc's diameter as a multiple of the window's width and `padding` how far the ring
stands off the target.
### The FAB does not move, and it stays the FAB
The legacy `FeatureDiscovery` took a `targetRef`, measured it, and drew a **copy** of
whatever the caller passed as `highlightContent` over the disc. A copy is a picture: it does
not press, and it is only correct for as long as somebody keeps it in step with the original.
`FabDiscovery.Target` **is** the `Fab`. While the mark is up it is lifted into the portal at
its own measured rectangle — the coordinates it already occupied — so it draws above the
disc, still presses, and never appears to move. The node left in the flow stays mounted and
invisible, because it is what holds the space the layout gave the FAB and what `onLayout`
measures; it is taken out of the accessibility tree while it is a placeholder, or a screen
reader would find the same button twice.
The layers are **numbered** rather than left to mount order. The target and the disc each
open a portal, and which one landed on top would otherwise depend on the order two slots
were written in — a FAB under its own disc, from moving one line. `zIndex` alone is not
enough on Android, which draws a native Z from `elevation` that a React `zIndex` does not
outrank, so the layer holding the FAB carries more of both.
### The text is laid out to the chord, not to the diameter
`discoveryGeometry` is a pure function — eleven tests — and this is the part worth reading.
The block of text is placed at a `y`, and how wide the disc is at that `y` is the chord of a
circle, `√(r² − dy²)`: a block near the disc's centre is nearly its full width, and one near
the top or bottom is a sliver. Laying the text out at the disc's width instead runs it past
the curve at both ends, which is the shape every first coach mark has.
Where that chord is too narrow to read a paragraph in, the block gives up on the disc and
sets from the screen's own edge — on the side the target is **not** on, so the words run away
from the thing they describe rather than under it.
**The block is pinned by the edge nearest the target**: by its top when it sits below one,
by its **bottom** when it sits above. That is what keeps the gap to the FAB fixed however
long the description runs — the legacy set a `top` at `targetY − 150`, so a coach mark that
gained a line grew down into the button it was pointing at. Only the block's _width_ still
needs a guess at its height, because the chord that bounds it is narrowest at the far end and
the far end is not known until the text has been laid out; that guess is one named constant,
and it errs wide.
## Accessibility
**A round FAB needs an `accessibilityLabel`.** A mark is not text and there is nothing beside
it to fall back on. An extended one has a label and does not.
`accessibilityState` carries `disabled` and `busy`, and `isLoading` sets the second.
## See also
- **`Button`** — the same seven intents, in a row of text.
- **`Menu`** — a list of actions dropping out of a control that is not a FAB.
- **`Dialog`** — for a question that has to be answered, rather than a feature explained.
- **`Portal`** — for a FAB that must escape a scroll container's clipping.
# FieldGroup
A field with something beside it — a glyph, a unit, a reveal toggle.
## Import
```tsx
import { TextField } from '@xaui/native/text-field'
import { FieldGroup } from '@xaui/native/field-group'
```
Two imports, because they are two components and the second one lives **inside** the first.
## Anatomy
```tsx
```
- **`FieldGroup`** — one row of the `TextField`'s column, and the positioning context the two
decorators are laid over. It owns exactly one thing: how wide they turned out to be.
- **`FieldGroup.Prefix`** — pinned to the leading edge, inset by the field's own padding.
Its measured width becomes the field's `paddingStart`.
- **`FieldGroup.Suffix`** — the same, on the trailing edge, as `paddingEnd`.
- **`FieldGroup.Field`** — `TextField.Field` with that padding. It **is** that field: the same
`TextInput`, the same focus plumbing, the same styles, every `TextInput` prop.
- **`FieldGroup.Icon`** — a glyph at the field's scale in the placeholder's colour.
**The label, the hint and the error are not here.** They are the `TextField`'s, and this
replaces nothing but the field — which is why there is no `variant`, no `size`, no `color`
and no `isDisabled` on this root: a second `size` here would be a second answer to a
question the field has already answered.
**The box is still the `TextInput`.** The decorators are taken out of flow and laid over
it, so no wrapper borrows the border, the fill, the radius and the shadow — a `FieldGroup`
and a bare `TextField` cannot drift apart, because there is only one box in the library and
this is not a second one.
**No slot carries a margin** (R4), and no decorator pushes the text: the field clears them
by padding.
## Usage
### With a prefix
```tsx
Recherche
```
### With a suffix that does something
```tsx
Mot de passe
setIsVisible(!isVisible)}
hitSlop={20}
accessibilityRole="button"
accessibilityLabel={isVisible ? 'Masquer' : 'Afficher'}
>
Douze caractères au moins.
```
The library ships no button for a suffix. A `Pressable` with a `hitSlop` is the thing to
put there — a 16pt glyph is not a touch target on its own.
### Decorative, or a control
```tsx
…
…
```
`isDecorative` does two things and they belong together: touches pass through to the field
underneath, so tapping the glyph focuses the field, and the content leaves the
accessibility tree, so a screen reader never stops on a mark it cannot act on.
It is **off by default**, because the trailing edge is where a decorator is most often a
control and a suffix that swallowed its own taps would be a reveal toggle you cannot press.
### Anything, not just a glyph
```tsx
+33
```
Nobody is told a number. The decorator measures itself and the field clears it by that
width, so a country code, two glyphs and a 16pt mark all land right.
### Multiline
`FieldGroup.Field` is `TextField.Field`, so `multiline` works on it — and `rows` and `maxRows`
do not, because those are the [`TextArea`](../text-area/text-area.md)'s and reach
`TextArea.Field` alone. A decorator spans the whole box, so over several lines it lands in
the middle of it; style props are what pin it where it belongs:
```tsx
```
### Disabled
```tsx
…
…
```
`isDisabled` is on the **`TextField`**, and it reaches everything: the column dims, the field
stops, and both decorators stop taking touches — including a suffix that is not decorative,
because a toggle you can still press on a disabled field is a bug rather than a feature.
### As another element
```tsx
…
```
R12 — the caller's element **is** the row. The decorators still measure into it.
## How the padding gets there
A decorator is out of flow, so it cannot push the text aside the way a sibling in a row
would. It measures itself with `onLayout`, reports the width to the root, and
`FieldGroup.Field` turns the two numbers into `paddingStart` and `paddingEnd`.
That is the same shape `TextArea` uses for `rows`: **a raw value the slot turns into a
style, outside the style cache** (R6). A width is not knowable before layout and takes as
many values as there are decorators, so it could never be a cache key.
They land after the recipe's own `paddingHorizontal`, which is what makes them win, and
before the caller's `style`, which still has the last word. `start` and `end`, never `left`
and `right` (R13): the prefix is the leading edge in both directions of writing.
The cost is one extra layout pass on mount, and the first frame draws before the width is
known. That is the trade the alternative avoids — a wrapper `View` carrying the box, with
the field flattened inside it — and the reason we did not take it is above: a second box is
a second thing to keep in step with `TextField`.
## Sizes
There is no `size` here. The `TextField`'s decides everything:
| `size` | Decorator inset | Gap | Icon |
| ------ | --------------- | --- | ---- |
| `xs` | 10 | 10 | 16 |
| `sm` | 12 | 12 | 18 |
| `md` | 12 | 12 | 18 |
| `lg` | 16 | 16 | 20 |
The inset is the field's own horizontal padding, so the glyph starts where the text would
have. The gap is that same step — the reference implementation's, and it is what separates two glyphs in one
decorator.
The icon sits **one step above the field's type**, exactly as on the `Button` and the
`Chip`: a 16pt glyph beside 16pt of text reads as an icon smaller than the text it sits
with. The reference implementation's component sizes no icon at all — theirs is a number at the call site — so
this is a slot they do not have rather than a value we disagree on.
## Colour
`FieldGroup.Icon` takes the theme's `fieldPlaceholder`: a mark in a field is decoration for
the text, not text. An explicit `color` wins, which is what `Icon` promises everywhere else
in the library, and it is the answer for a tinted `primary` or `secondary` field — there the
fill is the caller's colour and the placeholder grey is no longer readable over it:
```tsx
```
Everything else a tint touches — the border, the fill, the focus — is the `TextField`'s and
reaches the group untouched.
## Alignment with the reference implementation
Measured against their `input-group.tsx` and `input-group.css` rather than eyeballed.
**Identical:** the anatomy (a root, two decorators, a field), the decorators pinned out of
flow with a `zIndex` over the field, the 12pt inset and 12pt gap at `md`, the field cleared
by the decorator's _measured_ width on the logical edges, `isDecorative` doing exactly the
two things it does there — `pointerEvents="none"`, `accessibilityElementsHidden` and
`importantForAccessibility="no-hide-descendants"` — and the disabled cascade reaching both
decorators.
**Three deltas, all of them the shape of our `TextField` rather than a disagreement:**
| Theirs | Ours | Why |
| ----------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `isDisabled` on the group | on the `TextField` | Their `TextField` is the box and their `TextField` is the column; ours is the column already, and it holds the state the recipe resolves on. |
| `FieldGroup.TextField`, a pass-through to `TextField` | `FieldGroup.Field` | Our field is `TextField.Field`, so the slot keeps the field's name. |
| No icon slot — a size and a colour at each call | `FieldGroup.Icon` | The slot `Button`, `Chip` and `Alert` all have, so a form does not carry a hard-coded `#888` on every field. |
Their `animation="disable-all"` has no counterpart because nothing here animates: a
decorator that is laid over the field does not move when the field is focused.
## Props
### `FieldGroup`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| --------- | --------- | ------- | ------------------------------------------------ |
| `asChild` | `boolean` | `false` | Merge into the single child instead of rendering |
**No `variant`, `size`, `radius`, `color`, `isInvalid` or `isDisabled`.** They are the
`TextField`'s.
### `FieldGroup.Prefix`, `FieldGroup.Suffix`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| -------------- | --------- | ------- | ------------------------------------------------- |
| `isDecorative` | `boolean` | `false` | Touches pass through, and the content leaves a11y |
`onLayout` is composed, never replaced — the measurement happens and your handler runs.
### `FieldGroup.Field`
Everything `TextField.Field` accepts, which is everything `TextInput` accepts except
`editable`, plus the `TextStyle` keys as props (R14).
### `FieldGroup.Icon`
Everything `Icon` accepts. `size` and `color` default to the field's own; passing either
wins.
## Extending it
Both hooks are exported, so a third party can write its own decorator against the same
values the built-in two read:
```tsx
import { useTextField } from '@xaui/native/text-field'
import { useFieldGroup } from '@xaui/native/field-group'
function FieldGroupCounter({ length, max }) {
const { suffixStyle, icon } = useTextField()
const { setSuffixWidth } = useFieldGroup()
return (
setSuffixWidth(event.nativeEvent.layout.width)}
>
{length} / {max}
)
}
```
Reporting the width is what makes the field clear it. Skip that and the decorator still
draws — over the text.
Used outside a `` the hook throws by name, pointing at the misplaced component
rather than failing three frames later on an undefined width.
## Accessibility
- **The row has no role.** The control is the field inside it, and a role here would give a
screen reader a second element to stop on before reaching it.
- **A decorative decorator is hidden on both platforms** — `accessibilityElementsHidden` on
iOS, `importantForAccessibility="no-hide-descendants"` on Android — and only when
decorative: a reveal toggle is a control and has to stay reachable.
- **A control in a suffix names itself.** Give it an `accessibilityRole` and an
`accessibilityLabel`; a glyph has no text for a screen reader to read.
- **A prefix is not a label.** `+33` in front of a field says nothing to a screen reader
about what the field holds — write a `TextField.Label` as well.
## Migration from `@xaui/native-legacy`
The legacy component is `TextInput`, and these are the two props this replaces.
| Legacy | v1 |
| ------------------------------ | ------------------------------------------------------- |
| `startContent={ }` | `… ` |
| `endContent={ }` | `… ` |
| `isClearable` | a `Pressable` in a suffix that sets the value to `''` |
| `customAppearance={{ input }}` | `style` on `` |
# FlipCard
A card with two faces, and a turn between them.
## Import
```tsx
import { FlipCard } from '@xaui/native/flip-card'
```
## Usage
```tsx
Recto
Verso
```
## It paints nothing, and it has no recipe
What turns is two faces the caller supplied, and each of those is usually a `Card` with its
own variant, its own radius and its own shadow. A recipe here would be a second table saying
the same things, and the day one of them gained a border the other would not have it.
There is no `variant`, no `color` and no `radius` on a `FlipCard`. There is a `Card` inside
it, and those are its.
## The front decides how big the card is
The back is **out of flow** and fills it. A back in the flow would stack under the front and
double the height.
Two faces of different heights therefore take the front's — which is the only answer that
does not make the card resize halfway through its own turn.
## The two faces are a half turn apart at every moment
That is the whole mechanism. The away-facing side is not drawn
(`backfaceVisibility: 'hidden'`), so exactly one face is on screen at any angle:
| progress | front | back |
| -------- | ----- | ----- |
| 0 | 0° | −180° |
| 0.5 | 90° | −90° |
| 1 | 180° | 0° |
`faceAngle` is that relationship and it is tested, including the case it exists to prevent: a
back on a spring of its own, or a back at `progress × 180` rather than `(progress − 1) × 180`,
shows **both faces at once** through the middle of the turn.
It also follows past its own ends rather than clamping, which is what makes the spring's small
overshoot visible — a card that stops dead at a hundred and eighty degrees reads as a texture
swap rather than as an object with a weight.
`rotation="reverse"` negates **both** faces, so they still follow each other. Negating one
would make them meet.
## Perspective
`{ perspective: 1000 }` comes first in the transform list, and it has to: a rotation applied
before it is an affine squash, and the card reads as a blind closing rather than as a face
turning away.
It is the card's rather than a prop, because two cards on one screen at two depths look like
a mistake.
## Direction
`horizontal` (the default) spins about the vertical axis — the two faces swap left for right,
which is what a playing card does. `vertical` turns it about the horizontal one, which reads
as the card tipping towards you.
## Driving it
`isFlipped` / `defaultFlipped` / `onFlipChange`, controlled or not, as everywhere in the
library.
`isPressable={false}` leaves it a display and the flip to a control of yours — a button on
one face, a gesture, a timer:
```tsx
function TurnBack() {
const { flip } = useFlipCard()
return Revenir
}
```
`useFlipCard()` also carries `progress`, which is a **shared value**: an indicator that
follows the turn frame by frame is `useAnimatedStyle` over it, and it costs no re-render.
## Accessibility
Pressable, it is a `button` whose `accessibilityState.expanded` says which face is up. With
`isPressable={false}` it is an `image` — an element a screen reader announces as pressable and
which does nothing when pressed is worse than one it announces as a picture.
It carries **no press feedback of its own**: the turn is the feedback, and a card that also
dimmed under the finger would read as two things happening to it at once.
## See also
- **`Card`** — what usually goes on each face.
# InputOTP
A one-time code, one character to a box.
> **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of
> them; it ships under `1.x` as a P5 component. Nothing about its API differs — it follows
> the same fourteen rules as the `Input` it sits beside.
## Import
```tsx
import { InputOTP } from '@xaui/native/input-otp'
```
## Anatomy
```tsx
{({ slots }) => slots.map(s => )}
```
- **`InputOTP`** — the root. It owns the code as **one string**, renders a hidden
`TextInput` over the row, and publishes the resolved styles and the per-box state.
- **`InputOTP.Group`** — a row of boxes. It takes a **render function** as well as
elements.
- **`InputOTP.Box`** — one character's box. With no children it draws the value, the
placeholder and the caret.
- **`InputOTP.Value`** — the character, or nothing while the box is empty.
- **`InputOTP.Placeholder`** — what stands in for it before anything is typed.
- **`InputOTP.Caret`** — the blinking bar, in the box the next character lands in.
- **`InputOTP.Separator`** — the dash between two groups.
### There is one input, and it is hidden
Six focusable boxes is the design every OTP component starts with and every one of them
abandons: the caret has to be moved by hand, a backspace at the start of a box has to jump
backwards, and a paste arrives in one box out of six.
Here a single `TextInput` covers the row and holds the whole code. The boxes are a
_rendering_ of that string, so a keystroke, a backspace, a paste and an autofilled
`one-time-code` all take the same path — and the state that can disagree with itself does
not exist.
Two consequences worth knowing:
- **The hidden input takes every touch on the row.** That is what opens the keyboard from
a tap anywhere. A control that has to stay pressable goes _beside_ the `InputOTP`, not
inside it.
- **The input cannot be hidden by size or `display`.** An input the platform thinks is
invisible is one iOS refuses to focus and one the autofill heuristics skip — which would
cost the `one-time-code` suggestion that is the point of the component. It is
transparent instead, at a hair of opacity on iOS and zero on Android.
## Usage
### Basic
```tsx
const [code, setCode] = useState('')
{({ slots }) => slots.map(s => )}
```
Leave `value` out and the component keeps the code itself; `defaultValue` seeds it.
`onComplete` fires once the last box is filled, which is the only event most callers need.
### Groups and a separator
```tsx
{({ slots }) => slots.slice(0, 3).map(box)}
{({ slots }) => slots.slice(3).map(box)}
```
Splitting is a matter of slicing what you are handed, which is why there is no `groupSize`
prop.
### What `InputOTP.Group` is, and is not
It is **the unit a `Separator` sits between**, and the node the render function belongs to.
It is not an input, and there is never more than one of those however many groups you
write — the hidden `TextInput` is the root's, it covers the whole row, and every box in
every group reads the same string.
**It buys no spacing today.** Its resolved style is the root's, line for line: a row, centred,
with the same gap. The break you see between `482` and `913` comes from the separator's own
width plus the gap on either side of it — `8 + 8 + 8` — which a flat row of boxes and a
separator would produce identically:
```
□ □ □ [8] ▬ [8] □ □ □
└8┘
└──── 24 ────┘
```
What it keeps open is the ability to make those two gaps differ — a tight one inside a
group, a loose one around the separator — which is a change to one number in the recipe
rather than a change of shape. That is the reason it stays: a single-group code pays one
nested view for it, and that is the whole cost.
### The render function
`InputOTP.Group` is the one slot in the library that takes a function, because the number
of children here is **data** — `maxLength` — rather than markup. Writing six boxes by hand
is a list that silently disagrees with the prop the moment either changes.
It is handed `{ slots, value, maxLength, isFocused, isDisabled, isInvalid }`. Elements
still work if you want to write them out.
### Composing a box
```tsx
```
The three built-in children each return `null` when they do not apply, so all three stay
mounted and the box never changes its tree as the code is typed.
### Pattern
```tsx
import { OTP_DIGITS, OTP_LETTERS, OTP_ALPHANUMERIC } from '@xaui/native/input-otp'
;
…
```
Tested against the **whole value**, not each character, so a rule about the shape of a code
works as well as one about its alphabet. A string is compiled once; a `RegExp` you built is
used as it is, so its flags stay yours.
### Paste
Nothing to configure. A one-time code arrives surrounded by prose far more often than
alone — _"Your code is 482913, it expires in 10 minutes"_ — and a naïve `slice(0, 6)` takes
`"Your c"`. The component looks for a run of exactly `maxLength` digits with **no digit on
either side**, which is what rules out the `10` in that sentence and the year in a date.
`maxLength` is deliberately **not** set on the hidden input: the platform would truncate the
message before there was anything to look inside.
### Placeholder
```tsx
{/* every box */}
{/* one per box */}
```
Shown only where there is neither a typed character nor the caret. That three-way choice is
made in one place, so two of them never appear at once.
### Imperative
```tsx
const otp = useRef(null)
…
otp.current?.focus()
otp.current?.clear()
```
**`ref` is not the view.** It is `focus`, `blur` and `clear` — the three things only the
hidden input can do, and the reason the usual "the root forwards its node" shape is
inverted here. `clear` empties both halves, the native buffer and the value; emptying one
and not the other is how a cleared field comes back on the next keystroke.
### Sizes
| `size` | Box | Character | Gap |
| ------ | ------- | --------- | --- |
| `sm` | 40 × 36 | 16/24 | 8 |
| `md` | 48 × 44 | 18/28 | 8 |
| `lg` | 56 × 52 | 20/28 | 10 |
`md` is the reference implementation's OTP measured. The width is the control height less one spacing step, which
reproduces their 48 × 44 and holds the proportion at the other two.
### The corner
The box takes `lg`, 12 points — **not `field`**, which is what the rest of this family uses.
A field is wide, so 21 on a 48-tall one reads as a rounded rectangle. A code box is very
nearly square — 44 by 48 at `md`, 36 by 40 at `sm` — where the geometric maximum is 22, so
the same 21 is a pill in all but name and is clamped to one outright at the small end.
Twelve is where the reference implementation lands for the same box from the other direction: their `field` radius
is their `xl`, and their scale's base is 8 where ours is 12.
**There is no `xs`**, where the rest of the library has four sizes. That box would be 28 by
32, and it still has to carry an 18pt character to stay legible — 18 in 28 leaves no room
for the two-point active ring without the digit touching it. A code is also the one field a
user reads back to themselves character by character, which is the worst place to save eight
points. Below `sm`, use fewer boxes rather than smaller ones.
### Variants
Three of the `Input`'s four, token for token — a box of a code is a field one character wide.
| `variant` | Background | Border | Active ring | Shadow |
| ----------- | ----------------- | ------------- | ----------- | ------- |
| `primary` | `fieldBackground` | `fieldBorder` | `accent` | `field` |
| `secondary` | `default` | `fieldBorder` | `accent` | — |
| `tertiary` | transparent | `fieldBorder` | `accent` | — |
**The active box takes a two-point ring** in the accent. The reference implementation uses `outline-width: 2px`;
React Native has no `outline`, so it is a border — and two points rather than one, because a
box that gains a colour without gaining weight reads as a rendering artefact next to five
that did not. The box has a fixed width and height and centres what it holds, so the extra
point eats into the padding rather than moving anything.
**Three levels, not the `Input`'s four.** There is no `ghost`, and the shape of the
component is what removes it: an input is one wide field whose position the caret and the
label already give away, so it survives having neither fill nor edge. A code is six boxes,
and their only job before anything is typed is to say **how many characters are expected
and where they go** — with no fill and no border there is nothing to count. It is the
reason the `Checkbox` has no `ghost` either.
### `isInvalid`
Every box goes `danger`, and the active ring is **taken off**: the red is already on all of
them, and a seventh colour on one says nothing it did not.
### Colour
```tsx
…
```
A raw tint, never a token (R7). It lands where the variant put its tokens **and** becomes
the active ring — the active colour is a role like any other, so nothing extra is passed.
## Props
### `InputOTP`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ---------------- | ----------------------------- | ------------- | ---------------------------------------- |
| `maxLength` | `number` | — (required) | How many boxes, and how long the code is |
| `variant` | `InputOTPVariant` | `'secondary'` | The three levels above |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The box, the character, the gaps |
| `radius` | `RadiusKey` | — | Overrides the theme's `lg` radius |
| `color` | `string` | — | A hex tint, placed by the variant |
| `value` | `string` | — | Controlled |
| `defaultValue` | `string` | `''` | Uncontrolled seed |
| `onChangeText` | `(value: string) => void` | — | Every change, controlled or not |
| `onComplete` | `(value: string) => void` | — | Fires when the last box fills |
| `pattern` | `string \| RegExp` | — | Tested against the whole value |
| `placeholder` | `string` | — | One per box, or one for all |
| `isInvalid` | `boolean` | `false` | Danger boxes, no active ring |
| `isDisabled` | `boolean` | `false` | Dims the row, stops the input |
| `inputMode` | `TextInputProps['inputMode']` | `'numeric'` | The keyboard the hidden input asks for |
| `textInputProps` | `TextInputProps` | — | Anything else for the hidden input |
**No `asChild`** — the one root in the library without it. This root is not a pass-through
container: it owns a hidden `TextInput` that has to be its own child, and `Slot` merges into
a _single_ element. Wrap the `InputOTP` instead of replacing it.
### `InputOTP.Group`
Everything `View` accepts plus the `ViewStyle` keys (R14). `children` is a node **or** a
function of `{ slots, value, maxLength, isFocused, isDisabled, isInvalid }`.
### `InputOTP.Box`
Everything `View` accepts plus the `ViewStyle` keys (R14), and `index` — which box this is.
With no children it renders the placeholder, the value and the caret.
### `InputOTP.Value`, `InputOTP.Placeholder`
Everything `Text` accepts plus the `TextStyle` keys (R14). `maxFontSizeMultiplier` is capped
at `1.6`: a box has a fixed width, so a character scaled to 200% would be cut in half rather
than made larger.
### `InputOTP.Caret`
The `ViewStyle` keys as props (R14), plus `animation` — `false` stops the blink and leaves
the bar, so nothing moves.
### `InputOTP.Separator`
Everything `View` accepts plus the `ViewStyle` keys (R14).
## Extending it
Two hooks are exported: `useInputOTP()` for the whole component's resolved styles and
state, and `useInputOTPBox()` for one box's own slot. `buildSlots` and `extractPastedCode`
are exported too, because a caller reimplementing the paste rule is a caller who will get it
subtly different.
```tsx
import { useInputOTP } from '@xaui/native/input-otp'
function OTPProgress() {
const { value, maxLength } = useInputOTP()
return (
{value.length} / {maxLength}
)
}
```
## Accessibility
- **The hidden input is the control**, and it carries the label, the disabled state and an
`accessibilityValue` reading "n of m entered" — so the boxes are decoration and a screen
reader never walks six separate elements.
- `autoComplete="one-time-code"` and `textContentType="oneTimeCode"` are set, which is what
puts the code in the keyboard's suggestion strip. Both depend on the input being
_transparent_ rather than hidden.
- **`InputOTP.Separator` is hidden from the accessibility tree.** The value is read off the
hidden input, which has no gap in it — announcing a dash in the middle of a code would be
reading punctuation that is not in the value.
- The caret is ours rather than the platform's, because the real one belongs to an input
stretched across the whole row and would sit wherever the invisible text ends.
## Migration from `@xaui/native-legacy`
There is none: the legacy package has no OTP component. This is a new one.
# LineChart
A line per series, over a shared scale.
```tsx
import { LineChart } from '@xaui/native/line-chart'
; `${value / 1000}k`}
/>
```
Everything about the plot, the palette, the axes and the API's shape is in
[`chart.md`](../chart/chart.md) — this page is only what is this chart's own.
## Its own props
| Prop | Type | Default | Notes |
| ------------- | ------------------------ | ------------ | ------------------------------- |
| `curve` | `'monotone' \| 'linear'` | `'monotone'` | The curve never leaves its data |
| `strokeWidth` | `number` | `2` | |
| `hasPoints` | `boolean` | `false` | A dot on every reading |
`hasPoints` is off by default because twelve months of dots is a dotted line. It earns itself
on a short series, where each reading is a thing you point at rather than a trend.
The cap and the join are round: a line ending in a square cap reads as clipped at the axis,
and every trend line ends at the edge of its plot.
# List
## Overview
`List` virtualise une collection avec le `FlatList` de React Native. Il fournit la
structure visuelle d'une ligne sans lui attribuer de comportement de sélection ou de
pression.
## Anatomy
```tsx
```
## Usage
```tsx
person.id}
renderItem={({ item }) => (
{item.avatar}
{item.name}
{item.role}
{item.action}
)}
/>
```
## Props
Le root accepte les props verticales de `FlatList`, ainsi que les style props de vue. Les
props qui changeraient son orientation ou son séparateur sont volontairement réservées afin
de garantir le contrat visuel du composant. `asChild` permet de les fusionner dans un root
personnalisé compatible avec `FlatList`.
## Slots
`Item` compose une ligne. `Leading` et `Action` centrent leurs contenus. `Content` prend
l'espace restant et empile `Title` et `Description`.
## Variants
`List` n'a pas de variant d'apparence : son fond reste celui de son parent et ses espacements
proviennent du thème.
## Accessibility
Le root est annoncé comme une liste. Chaque ligne conserve les propriétés d'accessibilité
fournies à `List.Item`, et le contrôle placé dans `Action` conserve sa propre sémantique.
## Migration from legacy
Contrairement à `ListBuilder`, la nouvelle API reçoit le contrat standard de `FlatList` et
compose la ligne avec des slots. La sélection et la pression appartiennent au contrôle rendu
par l'application, pas à la liste.
# ListBox
Rows on a ground.
## Import
```tsx
import { ListBox, ListBoxGroup } from '@xaui/native/list-box'
```
One import, because they are one component and a group with no lists in it is nothing.
## Usage
```tsx
Wi-Fi
Maison
```
## Anatomy
| slot | what it is |
| ------------------------- | ------------------------------------------------------ |
| `ListBox` | The ground, and the thing that draws the separators |
| `ListBox.Item` | One row, inert |
| `ListBox.ItemButton` | One row you can press, in place of `ListBox.Item` |
| `ListBox.ItemPrefix` | What leads it — an icon, an avatar, a checkbox |
| `ListBox.ItemContent` | The text column, and what pushes the suffix to the end |
| `ListBox.ItemTitle` | What the row is |
| `ListBox.ItemDescription` | What it is currently set to, usually |
| `ListBox.ItemSuffix` | What trails it — a value, a switch, a chevron |
## It is the `Accordion` with rows that do not open
Same ladder, same separator inset, same one variant lifted. That is deliberate: a list in
`primary` **is** a card with rows in it, and two containers that look alike but are declared
apart drift until a list on a card sits one shade off it.
They will eventually share a container — the `Surface` refactor that `Card`, `Popover`,
`Accordion` and `Dialog` are all waiting on. Until then they at least name the same tokens.
## The separators are the root's
Drawn **between** the children rather than by them. A row that drew its own would draw one
under the last row too, and every list would start by hiding it.
The fill is the root's for the same reason: a row that painted its own would stack two fills
where the hairline sits, and the hairline would disappear into the seam.
The hairline stops where the text starts rather than running the full width — a separator
under a row's own inset reads as a rule the rows hang off, where one that starts with them
reads as the gap between two rows, which is what it is.
`ghost` has no edge for its separators to be inset from, so its rows run the full width and
the hairline runs with them. That is the difference between a list in a box and a list on a
page.
`hasSeparator={false}` leaves the rows to run together.
## It does not select
There is **no `selectionMode` and no `selectedKeys`**. Picking one of several things is what
`Select` and `Menu` are; a list that owned a selection would be a second, quieter menu with
none of the affordances.
A row that toggles carries the control that toggles it:
```tsx
Wi-Fi
```
which says out loud what it does, and is reachable by a screen reader as the control it
actually is.
## The suffix draws nothing of its own
The reference implementation's puts a chevron there by default. The trailing end of a settings row is a `Switch`
at least as often, and a slot that guesses makes you pass a child in order to render
nothing. The library ships `ChevronDownIcon`; a row that wants one says so.
## A plain row does nothing, and shows nothing
**A list is not necessarily a list of buttons.** Most of them are a table of facts: a value
beside a label, a switch that is its own control. `ListBox.Item` is therefore a `View` — no
press state, no wash, no role — because a row that lights up under a finger it never
responds to is a promise the component does not keep.
A row you can press is `ListBox.ItemButton`, used **in place of** `ListBox.Item`:
```tsx
Wi-Fi
Sécurité
```
It is the same row — same inset, same slots, same separators around it — plus the two things
a plain row must not have: the wash under the finger and the `button` to announce.
**Structural rather than inferred.** A single `Item` that turned pressable when it was given
an `onPress` would still be guessing, and the guess would be invisible in the JSX: two rows
that read identically would behave differently. Which of the two a row is, the JSX says.
`isDisabled` on the root stops every `ItemButton`; on one of them it stops that one.
`accessibilityRole` stays overridable (R9).
The root announces itself as a `list`. That is overridable too — a list of one row is a row,
and a list used as a plain container is neither.
## `ListBoxGroup` — the sectioned list
```tsx
Réseau
Wi-Fi
Le Wi-Fi se coupe en veille.
Confidentialité
…
```
**It is a group of lists, not a list with headings in it.** A `ListBox` draws its container and
its separators **between its own children**, so a heading placed among the rows would get a
hairline above and below it and would sit inside the card it names. Sections are containers
side by side; a heading belongs outside them.
**`ListBoxGroup.Section` exists because proximity is the only thing grouping a header with its
list** — nothing draws a box around a section. One gap on the group would put a heading
exactly as far from its own rows as from the section above it, so there are two gaps, on the
two roots that own them (R4). The ratio is the whole design.
**The header is inset by the row's own padding**, read off the `ListBox`'s size table rather
than guessed, so the heading and the text it heads share a left edge. `ListBoxGroup.Footer` is
inset with it — the sentence under a settings block that says what the switch actually does.
**Nothing is walked and nothing is counted.** The group publishes two gaps and a type scale;
the sections are ordinary children, and a section can be built out of something that is not
a `ListBox` at all.
### What the group hands down
`variant`, `size`, `radius`, `color` and `hasSeparator` are **defaults**, and a list that
names its own wins: a settings screen is uniform, and setting `variant` on five lists is
five chances to set it differently. `isDisabled` is the one that is not a default — a
disabled group has no live list in it.
```tsx
Du groupe
…
À elle
…
```
A `ListBox` outside any group is unchanged.
### Alignment with the reference implementation
Their `ListBoxGroup` **is our `ListBox`** — a Surface container with Item · ItemPrefix ·
ItemContent · ItemTitle · ItemDescription · ItemSuffix, slot for slot. What is here under
that name is the thing neither of us had: the sections, their headings and the spacing
between them. Two components rather than one renamed, because a settings screen is a column
of lists and a list is a column of rows, and those are two different columns.
### `ListBoxGroup` props
Everything `View` accepts, every `ViewStyle` key it does not claim (R14), plus:
| prop | type | default | description |
| -------------- | --------------------------------------------------- | ------- | ----------------------------- |
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | — | Default for every list |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Gaps, heading type, the inset |
| `radius` | `RadiusKey` | — | Default for every list |
| `color` | `string` | — | Default for every list |
| `hasSeparator` | `boolean` | — | Default for every list |
| `isDisabled` | `boolean` | `false` | Every list, and none opts out |
| `asChild` | `boolean` | `false` | Renders the caller's element |
`ListBoxGroup.Section` takes `View`'s props, `ListBoxGroup.Header` and `ListBoxGroup.Footer` take
`Text`'s, all three plus their style props (R14). `useListBoxGroup()` is exported (R10).
`ListBoxGroup.Header` carries `accessibilityRole="header"`, overridable — that is what lets a
screen reader jump between sections. The footer carries none: a footnote is prose, and
announcing it as a heading would put it in the list a reader jumps between.
## Props
| prop | type | default | description |
| -------------- | --------------------------------------------------- | --------- | ------------------------------ |
| `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'ghost'` | `primary` | Which ground |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Inset, type and corner |
| `radius` | `RadiusKey` | — | Overrides the corner |
| `color` | `string` | — | The tint (R7) — a raw value |
| `hasSeparator` | `boolean` | `true` | Hairlines. The group's, in one |
| `isDisabled` | `boolean` | `false` | Stops every row |
| `asChild` | `boolean` | `false` | Renders the caller's element |
## Two layers, and one of them is a platform constraint
The root carries the subtle `field` shadow and the border and must **not** clip: on iOS
`overflow: 'hidden'` sets `masksToBounds`, which cuts the layer's own shadow off with
everything else, and a raised `primary` would have none. It deliberately sits closer to
the page than a card, whose stronger elevation uses the `surface` shadow.
So the clipping is one layer in. It is the only extra node in this component, and without it
the press wash on the first and last rows paints square over the container's rounded corner.
The `Accordion` carries the same pair, for the same reason.
## Everything else is a style prop
`padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on
every slot. There is nothing here a prop had to be invented for.
# MaskField
A value typed into a shape — a date, a time, a card number, or a pattern of your own.
## Import
```tsx
import { MaskField, parseMaskedDate } from '@xaui/native/mask-field'
```
## Usage
```tsx
Card number
parseMaskedDate(text, 'fr-FR')}
onValueChange={(text, value) => setBirthday(value as Date | null)}
>
Date de naissance
Jour, mois, année.
```
## It is a `TextField`
The root below is the `TextField`'s root, unchanged: the same recipe, the same four
variants, the same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and
`isDisabled`. `MaskField.Label`, `.Description` and `.Error` **are** the `TextField`'s slots
— the same components, re-exported rather than wrapped — and only `MaskField.Field` differs,
by masking what is typed into it. The `TextArea`'s arrangement exactly, and `TextField` is
not touched.
## It is a mask, not a set of segments
There is one representation — the accepted characters, in order — and `maskInput` is the
only thing that turns them into text. Everything else is dropped, including the literals,
which are **put back** rather than kept.
That is what makes the field survive a paste, a keyboard that offers its own punctuation,
and a backspace over a separator, none of which a segmented field survives without a rule
each.
## `mask` is a preset or a pattern
| preset | shape | rules |
| --------------- | --------------------- | ------------------------------------------------------------------ |
| `'date'` | `DD/MM/YYYY` | order and separator from `locale`; each part clamped, never raised |
| `'time'` | `HH:MM` | 24-hour; hour ≤ 23, minute ≤ 59 |
| `'datetime'` | `DD/MM/YYYY HH:MM` | the two above, joined by a space |
| `'credit-card'` | `#### #### #### ####` | shape only |
`MASK_FIELD_MASKS` is the list of them.
Anything else is a **pattern string**:
| token | accepts |
| ------- | ---------------------------------------- |
| `#` | a digit |
| `A` | a letter |
| `*` | either |
| _other_ | a literal, put back in as the parts fill |
```tsx
```
A pattern is a shape, not a range — it does not clamp. If a custom mask needs clamping, that
is a follow-up, an object form `{ pattern, clamp }`.
### The date parts come from the locale
Read out of `Intl` rather than off a table of countries. `order` overrides it when the
order is a decision rather than a locale — an ISO field is `YMD` wherever it is read.
`separator` and `segmentLabels` (`{ day, month, year }`) override the rest.
A date that cannot exist — the 31st of February — is capped by the mask the moment the
month is known, and `parseMaskedDate` returns `null` for one typed before it, rather than
rolling forward into March the way `new Date` would.
## The value is the masked string
`value` / `defaultValue` / `onValueChange` operate on the masked text, controlled or not, as
everywhere in the library. A raw string handed to `value` is masked on the way in.
`convert` is the one plug that turns the text into a value of your own:
```tsx
…} />
```
`parseMaskedDate(text, locale?, order?)` → `Date | null` and `parseMaskedTime(text)` →
`{ hours, minutes } | null` are exported for the `date` and `time` shapes, with
`formatMaskedDate` / `formatMaskedTime` as their inverses. Without `convert`,
`onValueChange`'s second argument is the masked text itself.
## Slots
| slot | node | what it is |
| ----------------------- | ----------- | ----------------------------- |
| `MaskField` | `TextField` | the field |
| `MaskField.Label` | `Text` | the `TextField`'s label |
| `MaskField.Field` | `TextInput` | the box, masked |
| `MaskField.Description` | `Text` | the `TextField`'s description |
| `MaskField.Error` | `Text` | the `TextField`'s error |
`MaskField.Field` takes the number pad for a digit shape and the default keyboard for one
with letters, and caps the caret at the shape's own rendered length. Inside a `FieldGroup`
it leaves the decorators their room, exactly as `FieldGroup.Field` does — so a `$` prefix or
a clear button sits beside it without the text running under.
## For a date chosen rather than typed
That is `DatePicker` — a trigger, an overlay and a `Calendar` in it. `MaskField` is the box
alone; it has no picker.
## Props
Every node takes R14's style props for its own style type and forwards `ref`, `testID` and
the a11y props. `useMaskField()` is exported — it returns the masked text, the keystroke
handler and the shape, so a field of your own is written the same way the shipped one is.
# Menu
A list of actions, anchored to whatever opened it. The `Popover`'s positioning with rows in
it.
## Import
```tsx
import { Menu } from '@xaui/native/menu'
```
## Anatomy
```tsx
…
…
…
…
```
- **`Menu`** — state and resolved style. **It renders no node.**
- **`Menu.Trigger`** — what opens it, and the rectangle it anchors to.
- **`Menu.Overlay`** — the backdrop, and what closes it on a press outside.
- **`Menu.Content`** — the panel, positioned against the trigger, in a portal.
- **`Menu.Label`** — a heading over a run of rows.
- **`Menu.Separator`** — a rule between two runs of them.
- **`Menu.Group`** — that run, announced as a group.
- **`Menu.Item`** — one action.
- **`Menu.ItemTitle`** / **`Menu.ItemDescription`** — its two lines.
- **`Menu.ItemIndicator`** — a fixed box at either end of a row.
## Usage
### Basic
```tsx
Actions
Renommer
Supprimer
```
Choosing a row closes the menu, **after** the caller's `onPress` has run: a handler that
reads the menu's state has to run while there is still a menu.
### A row that does not close
```tsx
setCount(n => n + 1)}>
Encore une
```
For a row that toggles something the reader will want to toggle again.
### Headings, groups and rules
```tsx
Ce document
…
Supprimer
```
`Menu.Label` and `Menu.Group` draw nothing; `Menu.Separator` is the rule, and **you place
it**. A menu of four related actions wants none; a menu whose last row is "Supprimer" wants
exactly one, above it. Drawing them between every pair and asking for the exceptions is the
wrong way round — a menu is short enough that the one place a break belongs is obvious to
whoever wrote it, and invisible to the component.
It runs the panel's full inner width rather than lining up with the rows' text: a rule
inset to the titles reads as belonging to the row under it, and this one belongs to
neither. It is hidden from screen readers, because announcing "separator" between every
pair of actions is noise in the one place a menu has to be brisk.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `Menu`
| prop | type | default | description |
| -------------- | --------------------------- | ------- | ---------------------------- |
| `radius` | `RadiusKey` | — | Overrides the panel's corner |
| `isOpen` | `boolean` | — | Controlled |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close |
| `isDisabled` | `boolean` | `false` | Dims the trigger, stops it |
There is no `variant` on the root. A menu is the theme's floating surface, like the
`Popover`. The intent belongs to the **row**.
### `Menu.Content`
The `Popover.Content` props exactly — `placement` (four sides), `align`, `width`, `offset`,
`alignOffset`, `avoidCollisions`, `insets` — with one difference: `offset` defaults to
**6** rather than 9, because a menu belongs to the control it drops out of and a popover
belongs to nothing.
### `Menu.Item`
| prop | type | default | description |
| --------------- | ----------------------- | --------- | ----------------------------------- |
| `variant` | `'default' \| 'danger'` | `default` | Paints the title and its icon |
| `isDisabled` | `boolean` | `false` | |
| `closesOnPress` | `boolean` | `true` | Whether choosing it closes the menu |
| `asChild` | `boolean` | `false` | |
`children` may be a function taking `{ isPressed, isDisabled }`.
### `Menu.ItemTitle` · `Menu.ItemDescription` · `Menu.Label`
Everything `Text` takes, plus `TextStyle` as props. The title carries the row's intent and
takes the row's width, so an indicator stays pinned to the end whatever the label's length.
The description stays **muted whatever the intent**: a `danger` row says what it does in
red once, and a red sentence under it says it twice.
### `Menu.ItemIndicator`
A fixed 20-point box. Its size does not depend on what is in it, so two rows whose
indicators differ still line their titles up. It renders nothing on its own, unlike the
`Select`'s — a menu has no selected row to mark.
## What it shares
The positioning is the `Popover`'s, hook for hook: `utils/placement.ts`,
`hooks/use-anchor-ref.ts`, `hooks/use-anchored-position.ts`, `system/anchored/`. This is
the third component to read them, after the `Select` and the `Popover`.
## Not here yet
**`SubMenu`.** The reference implementation ships it as its own component and it needs a second anchored panel
whose trigger is a row of the first — worth its own change rather than a corner of this one.
## Accessibility
The trigger is a `button` carrying `expanded`. Rows are `menuitem`, groups are `menu`, and
`Menu.Label` is a `header` so the group it opens is announced with it. The overlay announces
nothing — it is the absence of the menu.
## Migration from `@xaui/native-legacy`
The legacy component is `Menu`, with `MenuItem`.
| Legacy | v1 |
| -------------------------- | ----------------------------------------- |
| ` ` | `…` |
| `description="…"` | `` |
| `isDestructive` | `variant="danger"` |
| `icon={…}` | an `` inside `` |
| `customAppearance={{ … }}` | `style` on the slot that key named |
# MorphButton
A button that changes shape: a pill at rest, a card once it is open.
## Import
```tsx
import { MorphButton } from '@xaui/native/morph-button'
```
## Usage
```tsx
Primary
Primary
High-contrast inverted surface for floating buttons over app content.
```
## One box, two contents
The pressable **is** the shape that travels, and exactly one face is mounted at a time — so
the box takes its size from whichever that is, and Reanimated's layout transition animates
it between the two.
**Nothing is measured.** No hidden pass, no `maxHeight`, no `onLayout`: a card whose
sentence arrives from the network grows the box with it, instead of being stuck at the size
it had when it was measured. It is the `Accordion`'s panel, applied to the control's own box
rather than to a row under it.
That is also why the transition sits on the pressable rather than on a wrapper around it. A
transition on a wrapper animates the wrapper's frame while the pressable inside it is
already at its final size, so the content is drawn outside the shape for the length of the
spring. `PressableFeedback` carries a `layout` prop for exactly this.
The box clips (`overflow: 'hidden'`), which turns those same frames into the shape revealing
its content.
## One corner for both shapes
Half the collapsed height. At that height it is exactly a pill; on the taller card it is a
corner in proportion to the control's scale.
Two radius keys would have needed a compound of `size` and `radius` — a pill at `sm` is 20
points and a card rounded that much is a gélule — so the pair would have said what one
derived value says once. `radius` still overrides it.
## The measurements are on the faces, not on the box
`size` writes the collapsed height and side inset on `Collapsed`, and the card's four-sided
frame on `Expanded`. Since exactly one of them is mounted, the root takes its shape from
whichever that is — which is what removes the `size` × `isExpanded` compound this component
would otherwise need.
| `size` | collapsed height | pill inset | card frame | title | description |
| ------ | ---------------- | ---------- | ---------- | ----- | ----------- |
| `sm` | 40 | 14 | 16 | 16 | 12 |
| `md` | 48 | 16 | 20 | 18 | 14 |
| `lg` | 56 | 20 | 24 | 20 | 16 |
`xs` is absent rather than forgotten: a card that opens out of a 32-point control has less
room than the padding it would need, and the shape it morphs into is a pill with two lines
crammed into it.
There is no `fullWidth` and no width anywhere in the recipe. Without an `alignSelf` the box
fills its column in **both** shapes, which is RN's own behaviour and the `Button`'s.
## No `childrenToString`
R3 auto-wraps a text child everywhere else in the library. This is the one component that
legitimately skips it: a bare string could belong to either face, and wrapping it into the
collapsed one would build a button that morphs into an empty card. The two faces are the
API, and a missing one warns in development.
## Driving it
`isExpanded` / `defaultExpanded` / `onExpandedChange`, controlled or not, as everywhere in
the library. Pressing it toggles the shape, and `onPress` still runs.
`useMorphButton().toggle` is what a control **inside** a face is written against, and it
costs no state:
```tsx
function Close() {
const { toggle } = useMorphButton()
return Replier
}
```
A caller who does not want the press to close the card holds the state itself and ignores
the change when it is open — which is why there is no `isPressable` prop.
## The spring
`animation={false}` puts it in the other shape with no travel at all: the box jumps and the
faces swap without a fade. An object retunes it — `{ stiffness, damping, mass }`, merged
over `MORPH_SPRING`.
The default is slightly under-damped (ζ ≈ 0.85), so the shape settles with a hint of
overshoot rather than stopping dead: a box that arrives exactly at its target reads as a
layout swap, and the small overrun is what makes it read as one object changing shape.
The faces fade over 140ms — shorter than the travel, because they are never both on screen
and this is the arriving one catching up rather than a crossfade.
They fade from the **second** shape onwards. Reanimated runs an `entering` animation on the
first mount as well, so without that every button on a screen would fade its collapsed face
in as the screen arrived — and on the web would render `visibility: hidden` until the worklet
started. `defaultExpanded` therefore opens onto the card with no entrance of its own.
## Appearance
The `Button`'s seven variants, unchanged, and the card's two text slots read the same
foreground as the pill's label — the description turned down by opacity rather than
recoloured, because the ground is a raw `color` as often as it is a token and there is no
contrast colour in the theme for a colour the caller invented.
The pressed colour is the variant's own `…Pressed` token rather than a
`PressableFeedback.Highlight`, exactly as on the `Button`: both would darken the control
twice.
## Accessibility
A `button` whose `accessibilityState.expanded` says which shape it is in. A caller's own
`accessibilityState` is merged over that rather than replacing it, so adding `selected`
cannot silently drop the `expanded` a screen reader depends on.
## See also
- **`Button`** — the same table, on a box that does not change shape.
- **`Accordion`** — the same mounted-or-not height animation, on a panel rather than a control.
- **`FlipCard`** — two faces and a turn between them, where this is two shapes and a travel.
# NumberField
A number, typed — with the two ends of its range and a pair of buttons that walk it.
## Import
```tsx
import { NumberField } from '@xaui/native/number-field'
import { FieldGroup } from '@xaui/native/field-group'
```
## Usage
```tsx
Quantité
```
## It is a `TextField`
The root is the `TextField`'s root, unchanged: the same recipe, the same four variants, the
same `size`, `radius`, `color`, `labelPlacement`, `isInvalid` and `isDisabled`.
`NumberField.Label`, `.Description` and `.Error` **are** the `TextField`'s slots — the same
components, not wrappers. Only the field differs, by reading a number out of what is typed
into it. The [`MaskField`](../mask-field/mask-field.md)'s arrangement exactly.
## The value is a number, and the box is a string
Two representations, and the caret is what swaps them.
| where the caret is | what the box holds |
| ------------------ | -------------------------------------------------- |
| outside | the value written by `Intl`, `formatOptions`'s way |
| inside | the same number written plainly, no grouping |
**Out of the field**, `formatOptions` is `Intl.NumberFormat`'s own options, unchanged — a
currency, a unit, a fixed number of decimals cost nothing here.
**Into it**, everything that is not a digit, a sign or the decimal mark is **dropped rather
than refused**. The value the caret lands in is grouped, and rejecting its separators would
make the first keystroke on an existing value clear the box. A full stop passes as the
decimal mark wherever the locale is not using it to group, so `1.5` reads as one and a half
in `fr-FR` and `1.234` still reads as a thousand-odd in `de-DE`.
**On the way in the value is rewritten plainly**, so nobody has to type a euro sign back in.
`null`, never `NaN`: "not a number yet" is the state a half-typed field is in for as long
as it takes to type a minus sign, and `NaN` is a value that propagates instead of a state a
caller can test.
## The bounds land when the reader leaves, not while they type
`min={10}`, and a reader on their way to `15` types a `1` first. Clamping that would take
the keyboard away from them.
So until the field is left, `onValueChange` reports what is actually in the box. The clamp
falls on blur, and on every press of a stepper.
## The steppers
They are `FieldGroup` decorators, like `TimeField.Period` — that is the thing that lays a
control over a field and measures it, so the box stays the `TextInput` itself and no
wrapper borrows its border, its fill and its radius.
**`Decrement` takes the leading edge and `Increment` the trailing one.** The value sits
between them, which is the only arrangement in which the two read as one control rather
than as two marks that happen to be nearby. `start` and `end`, never `left` and `right`
(R13).
**A button goes flat when the value has nowhere left to go**, and stops taking presses
there. That is asked of the **result** rather than of the bound: a value half a step short
of the ceiling can still reach it, and a button dead at that point strands the reader.
With no children each draws its own mark — one bar, or two a quarter turn apart — so the
field works in a project that has installed no icon set. Pass an `` to replace it.
## From an empty field, a step starts at zero
Which is what puts the first press on `min` when the range starts above it: `min={5}` and a
step of one gives 5, not 6.
## The keyboard is a guess, and a guess you can override
A fractional `step`, or a `formatOptions` that asks for decimals, opens the decimal pad;
anything else opens the number pad. Unlike the `MaskField`'s, `NumberField.Field` does
**not** claim `keyboardType` — a field that has to take a minus sign needs a keyboard with
one on it, and that is the caller's call.
## Related
- [`NumberStepper`](../number-stepper/number-stepper.md) — the same pair without a field
to type into.
- [`Slider`](../slider/slider.md) — a quantity chosen on a track.
- [`TextField`](../text-field/text-field.md) — the root, the label, the hint and the error.
# NumberPad
The keypad a PIN, a code and an amount are typed on.
## Import
```tsx
import { NumberPad } from '@xaui/native/number-pad'
```
## Usage
```tsx
```
```tsx
```
## The grid is data, not markup
`1` through `9`, the `0` and the backspace are not a decision a caller makes, so the root
renders them. Eleven hand-written cells is a layout that disagrees with itself the moment one
of them is edited, and there is no `maxLength`-shaped prop driving the count the way there is
in `InputOTP` — which is the one component here whose children genuinely are data and which
therefore takes a render function.
What **is** composed is the one free corner of the bottom row, opposite the backspace, and
that is what `children` is. One cell. Left out, the corner is still a cell — without it the
`0` slides to the start of its row and stops being the middle column.
`NUMBER_PAD_ROWS` is exported, for a caller building a different pad out of the same cells.
## It draws no display
What the value looks like is the screen's — an `InputOTP`'s boxes, a row of dots, an amount in
a `Typography`. A pad that also rendered the value would be two components that have to agree
on a string, and the interesting half of that pair is always the one the pad did not
anticipate.
### A masked display is a composition, not a prop
There is no `isSecure` here, and there does not need to be. `InputOTP.Value` takes children
that win over the box's own character, so a PIN masks in one line:
```tsx
{({ slots }) =>
slots.map(slot => (
{slot.char ? '•' : undefined}
))
}
```
The lock screen's bare row of dots — no boxes at all — is a `View` per character filled up to
`value.length`, which is six divs and stays the screen's. A component for it would have to
pick a size, a gap, a colour and a shape on behalf of every lock screen that installs it.
Both are on the demo screen.
## The value
One string, controlled or not: `value` / `defaultValue` / `onChangeText`, as everywhere in the
library. `onComplete` fires the moment it reaches `maxLength` — the PIN screen's only event.
`maxLength` **clamps rather than truncating**. A press past the limit changes nothing at all,
so `onChangeText` does not fire and a full PIN cannot be completed twice by leaning on a key.
The insert is measured whole rather than as one character, because a key may carry more than
one: a `00` key on a currency pad that landed halfway over the limit would produce a value one
character longer than the pad promised.
Unset, `maxLength` is unbounded — which is what a pad in front of an amount wants.
## The cells
- **`NumberPad.Key`** — one filled key. `value` is what it inserts, and what it shows when
nothing is composed inside it. Exported for the eleventh key: a decimal separator, a `00`.
- **`NumberPad.Backspace`** — the delete key, bare. **A long press clears the whole value**,
the gesture every platform's own keypad carries; without it a mistyped sixteen-digit card
is sixteen presses to undo.
- **`NumberPad.Action`** — the free corner. It does nothing on its own, because what belongs
there is the caller's: a fingerprint that unlocks, a `Clear`, a decimal separator.
- **`NumberPad.Label`** and **`NumberPad.Icon`** — the character or the glyph on a cell.
### A cell owns its press state
The root cannot see which of eleven keys is down, so it resolves **both faces** of the cell
style and each cell picks. That is the `Menu`'s arrangement, for the same reason: nothing
re-resolves per key, so a pad of eleven costs what a pad of two would. R5 stays intact —
no slot touches the recipe.
### Bare cells read the page's foreground
`NumberPad.Label` and `NumberPad.Icon` need no prop to know which colour they take: the cell
they are in publishes it. A filled key reads the variant's foreground; a bare corner reads
the page's.
That distinction is load-bearing rather than cosmetic. A `primary` pad puts
`accentForeground` on its digits, and a backspace with no ground of its own would take white
on white. It is also why a raw `color` does not reach the bare cells — a glyph with nothing
tinted behind it has nothing tinted to read against.
## The backspace arrow is a character
`←`, not an icon, so the pad needs no `react-native-svg` for the one glyph it draws itself. It
flips to `→` under RTL, because a backspace points at what it removes and that is the other
way round in a right-to-left layout. Compose a `NumberPad.Icon` to replace it.
## Appearance
Five emphasis levels and no intent among them — there is no `danger` keypad, and `success`
and `warning` are outcomes rather than something you press.
`default` is what the pad ships as, where the `Button` ships as `primary`: eleven keys in the
accent is a wall of colour, and the digit is what the eye is looking for.
| `size` | key height | gap | corner | digit |
| ------ | ---------- | --- | ------ | ----- |
| `sm` | 60 | 8 | 18 | 24 |
| `md` | 72 | 10 | 18 | 30 |
| `lg` | 84 | 12 | 24 | 36 |
The height is **a control and a half**, derived from `controlHeights` rather than written out,
so a theme that raises its controls raises its keypad with them. A key is hit with a thumb
rather than pointed at, and it is hit eleven times in a row — a control-height key turns a PIN
into an exercise in precision. `xs` is absent for the same reason: 48 points holding a
24-point digit is a control pretending to be a key.
The gap is one value for both axes. A grid whose rows sit closer than its columns reads as
three separate rows rather than as one pad.
**No width anywhere in the recipe.** Every cell is `flex: 1`, so the pad is as wide as it is
given and the three columns divide that between them.
## Accessibility
Each cell is a `button`, labelled with what it inserts.
**Not `keyboardkey`**, which is the role that exists for exactly this and would have a screen
reader announce "1, key". React Native maps it to a trait on **iOS only**: on Android it falls
through to no role, and the web renderer emits a bare focusable `div` with no role at all. A
key that announces as nothing on two platforms out of three is worse than one that announces
as a button on all three. It is still only the default, so an iOS-only app passes
`accessibilityRole="keyboardkey"` on the cell.
The root is `none`. There is no keypad role in React Native, and reaching for `toolbar` or
`list` would have a screen reader announce the box as something it is not.
## See also
- **`InputOTP`** — the boxes this pad usually types into.
- **`NumberField`** — the same value with a keyboard instead of a pad.
# NumberStepper
A quantity, and the two presses that move it.
## Import
```tsx
import { NumberStepper } from '@xaui/native/number-stepper'
```
## Usage
```tsx
```
## It is not a `NumberField` without its box
A field is typed into and this is not. It has no keyboard, no caret, no parse, no
`isInvalid` and no bounds to apply late — a value that can only be pressed into existence is
inside its range at every moment.
What the two share is the arithmetic, and nothing else. `stepNumber`, `clampNumber` and
their rounding live in `utils/number.ts`, so neither component owns them and both read the
same answers.
| you want | reach for |
| ----------------------------------------------------- | ------------------------------------------------ |
| a small number, and the presses are the point | `NumberStepper` |
| a number long enough that pressing to it is absurd | [`NumberField`](../number-field/number-field.md) |
| where it sits in its range, more than its exact value | [`Slider`](../slider/slider.md) |
## The track is a slot, and it is written first
It is out of flow and painted behind everything after it, so its place in the JSX is what
puts it under the rest rather than over it. `Slider.Track`'s arrangement, and its reason: a
ground a caller cannot leave out is a ground a caller cannot replace either.
**The inset is the shape.** The buttons are the control's full height and the pill is
shorter, so the two circles stand proud of the ground between them. A pill as tall as its
buttons is a segmented control, which says "pick one" rather than "more of it".
It takes no touches and is hidden from the accessibility tree — a ground is not a control.
## The variant paints the buttons
Because they are what a finger is aimed at. The pill under them is the ground they are
raised off, and it is the same soft grey at every level that has a pill at all.
| `variant` | the buttons | the pill |
| ----------- | ------------------------------------- | -------- |
| `primary` | the accent, filled | yes |
| `secondary` | raised `surface`, hairline border | yes |
| `tertiary` | the border alone, no fill and no lift | yes |
| `ghost` | neither | no |
`secondary` is the default, and it is the one thing here that departs from the vocabulary
table: `default` is the token `secondary` usually names, and a `default` button on a
`defaultSoft` pill is two greys a shade apart, in which the button stops reading as raised
at all.
There are no intents. A stepper **reports nothing** — it is a quantity being set, not a
verdict — so `success`, `warning` and `danger` are absent, exactly as they are on the `Card`
and the `TextField`.
## Each button owns its own press state
Two buttons on one control are two targets: pressing the plus must not light the minus.
That is why the recipe has no `bgPressed` and the press is the shared `PressableFeedback`
treatment instead — the `CloseButton`'s arrangement, for the `CloseButton`'s reason.
**A spent button fades its mark and keeps its fill**, and stops taking presses. The mark
and not the box, because a button dimmed whole goes translucent — and a translucent button
stops hiding the pill it is raised off, so the ground reads straight through the circle. It
is also the wrong thing to say: the affordance is gone, the button is still there.
Which end is spent is asked of the **result** rather than of the bound: a value half a step
short of the ceiling can still reach it, and a button dead at that point strands the reader.
With no children each draws its own mark — one bar, or two a quarter turn apart — so a
stepper works in a project that has installed no icon set.
## A bin at the floor is `children` and a ternary
```tsx
{quantity === 1 ? : undefined}
```
Not a prop of its own. The condition is the caller's — it is a basket row's rule, not a
stepper's — and both halves of it are already props this slot has.
**A caller's own `onPress` replaces the step** rather than running beside it: removing the
row is not also decrementing it.
## The value
With no children it is the number written through `formatOptions`, and an **em dash** while
there is none — an em dash and not a zero, because a stepper that has never been pressed is
holding nothing rather than holding none.
A function child is given the number itself, for a unit, a plural, or a word in place of a
digit:
```tsx
{value => `${value ?? 0} ${value === 1 ? 'convive' : 'convives'}`}
```
It keeps the page's own ink whatever the buttons are painted, because it sits on the pill
rather than on a button.
## The number gives way, and never lays over a button
`Value` shrinks and then truncates: it is the part of the control that can afford to lose
room, because the buttons are touch targets. And the buttons carry a `zIndex` above it, so a
number that has run out of room can never end up painted on top of the leading circle —
which it otherwise would, since it is written between the two.
One line, always. A second line is a row taller than its own buttons. Pass
`numberOfLines={0}` if a `Value` of yours really has to wrap.
## Width
There is no `fullWidth`. The root hugs its content, and `alignSelf` is a style prop (R14)
like any other:
```tsx
```
# Pager
Whole pages, one at a time — the onboarding flow, the full-screen feed, the gallery a reader
swipes through.
## Import
```tsx
import { Pager } from '@xaui/native/pager'
```
## Usage
```tsx
```
## A `Pager` has no height of its own
Give it one: `flex={1}` to fill a screen, `height={320}` inside a scroll view.
`flex: 1` in the recipe would have been the convenient default and it is a trap. React Native
expands it to a zero flex-basis, which **overrides an explicit `height`** — so
`` would collapse to the height of its dots and the prop the caller
reached for would silently do nothing. It is the same reason there is no `fullWidth` on a
`Button`: RN's own behaviour is the answer, and the caller says which of the two they want.
The track keeps its `flex: 1` and takes whatever the root was given less the dots. Nothing
sizes it from outside, which is why it is safe there and not on the root.
## A page is the track, measured
Not a prop, and not a fraction of one: a pager's page is the viewport it sits in, on both
axes. It is measured once and handed down, because a page given a size in points is a page
that is wrong on the next screen.
**The measurement is the track's, not the root's.** The indicator sits in the flow under the
pages, so the root is taller than the box a page has to fill by exactly the height of the
dots — a page sized to the root would overflow it by that much on every screen.
Nothing is drawn before the track has been measured. A page at zero size would flash at the
start of the run, and every page would be at the same place.
## Where this is not a `Carousel`
The two share the paging arithmetic — `indexFromOffset` and `progressFromOffset` in
`utils/carousel.ts` are the same functions — and nothing else, because the jobs differ at
three points that each change the implementation:
| | `Pager` | `Carousel` |
| ------------- | ------------------------------------------------ | ------------------------------------------------------------ |
| A page's size | the whole track | a **division** of it, by `itemsPerView`, `peek` and the gaps |
| The snap | `pagingEnabled` — RN's own whole-viewport paging | `snapToInterval`, one slide plus its gap |
| The travel | `scrollTo({ animated: true })` | a hand-run tween on `easeOutCubic` |
| The axis | horizontal **or vertical** | horizontal |
| The controls | dots | arrows, dots, a counter, thumbnails, autoplay |
The travel is the one worth stating plainly. The `Carousel` eases by hand because a slide's
move is short enough for the platform's near-linear curve to read as a jump cut. A page's
move is a whole viewport, which is the distance the platform's own pagers travel on that same
curve — so here it is the right answer rather than the one to work around.
## Both axes
`orientation="vertical"` is the full-screen feed; `horizontal`, the default, is the
onboarding flow. It is stated as the **axis** rather than a direction, so it needs no RTL
branch: a horizontal pager mirrors with its scroll view, which is React Native's own
behaviour. The dots follow — a vertical pager's indicator is a column.
## The pages are yours and the dots are the library's
`variant`, `color` and `size` reach the dots and nothing else. A pager of screens and a pager
of photographs want opposite things inside a page, and the caller is the one who knows which
this is.
Three variants, none of them an intent — a pager reports nothing, it is a way of arranging
what does. They name **the indicator's colour**, and every dot takes it:
- `primary` — the accent. The onboarding flow's answer.
- `secondary` — the page's own ink, for an indicator that has to read as chrome.
- `tertiary` — the raised ground, white on a light theme.
`ghost` is absent rather than forgotten. A dot with no fill is not a dot, which is why
`InputOTP` has no `ghost` either — a box that is not a box is not a box.
A pager over a **photograph** wants `color="#ffffff"` rather than `tertiary`: a photograph is
a photograph in both colour modes, and `surface` flips with the theme.
### One colour at two opacities, not two colours
The current dot is at full strength and the rest sit at 30%. `DOT_REST_OPACITY` is exported,
and the travel between the two is interpolated from the live scroll offset.
This is the correction of the component's first shape, and it is worth stating why rather than
just what. The dots started as a **pair** of tokens — the current one from the variant, the
rest from the neutral `default` fill, which is what `Carousel` does. A pair has to be chosen
to contrast with itself, and it cannot be: `tertiary` put `surface` against `default`, which
measures `#ffffff` on `#e4e4e7` in light and two near-identical greys in dark. The one variant
that exists for a pager over an image was the one whose indicator could not be read, in both
colour modes.
One colour at two opacities cannot collapse like that — whatever the variant, whichever the
colour mode, and for any raw `color` a caller invents. It is also what iOS's own page control
does. 30% rather than 50%: the dots are seven points across, and at half strength a small mark
reads as the current one seen through something rather than as a mark behind it.
A style prop is still not the way to recolour one dot: ` `
lands after the recipe, so it paints that dot and leaves the opacity travel alone — which is
usually not what someone reaching for it meant. A caller who needs something the variants and
`color` do not offer composes their own dot against `usePager().offset`, which is what the
context publishes it for.
## The dot fades, it does not stretch
That is the difference from `Carousel.Dot`, and it is deliberate rather than a
simplification. A page control is a fixed row of marks saying how many screens there are and
which one you are on, and a mark that grows makes the row's arithmetic move under a reader
who is counting it. The carousel's pill is a _progress_ indicator over a series; this is a
position among screens.
It **follows the drag rather than the settle**: the opacity is interpolated from the live
scroll offset on the UI thread, so it travels while the finger is still down. Reading the
settled index instead would make it jump once per gesture, after the fact.
## The indicator sits in the flow
Under the pages. A caller who wants it over them says so, which is what the style props are
for:
```tsx
```
Absolute by default would have made the common case the one that needs undoing.
## Driving it
`index` / `defaultIndex` / `onIndexChange`, controlled or not, as everywhere in the library.
The index changes as the track **crosses** the halfway point rather than when it stops:
`onMomentumScrollEnd` never fires for a wheel or a trackpad, so on the web the index would
never move at all, and it lands after the fact, so a "Next" button would sit on the previous
page for the length of the deceleration.
`usePager()` publishes `goTo`, the settled `index`, the `count` the track counted itself, and
the live `offset` as a **shared value** — an indicator of your own that follows the drag frame
by frame is `useAnimatedStyle` over it, and it costs no re-render:
```tsx
function Skip() {
const { goTo, count } = usePager()
return goTo(count - 1)}>Passer
}
```
The count comes from the track counting its own children, so it cannot go stale the way a
number passed in beside them would.
## Accessibility
Each dot is a `button` whose `accessibilityValue` reads "3 of 5" — a dot has no text of its
own, and inventing one would be picking a language on behalf of every app that installs this.
The root carries no role. The track is the control and it announces itself; a role on the box
that holds it would have a screen reader describe a second thing.
## See also
- **`Carousel`** — a series of slides with arrows, a counter and thumbnails, where this is
whole screens with dots.
# PhoneNumberField
## Overview
A national phone number beside its country flag and calling code, with a searchable country
sheet. Imports from `@xaui/native/phone-number-field`. Run the `phone-number-field` demo
screen to try the real component in light and dark mode.
`libphonenumber-js` is an **optional** peer of this package, and this component needs it —
it supplies the calling codes, the country metadata and the national formatting. It is the
only component that imports it, so an app without a phone field never pays for it.
```bash
pnpm add libphonenumber-js
```
## Anatomy
```text
PhoneNumberField
Label
FieldGroup
FieldGroup.Prefix → Country
Field
Description / Error
Overlay
Content
Handle
Title
Search
CountryList
Close
```
## Usage
```tsx
import { FieldGroup } from '@xaui/native/field-group'
import { PhoneNumberField } from '@xaui/native/phone-number-field'
;
Phone number
We’ll send a verification code.
Choose country
```
Use `value` and `onValueChange` for a controlled field. The value is
`{ country: 'FR', nationalNumber: '0612345678' }`; an empty number is an empty string.
Changing country preserves the national text. Pasting a complete international number
selects its country when allowed by `countries`. Unsupported international pastes leave
the current value unchanged. Formatting runs on blur so deletion does not fight inserted
spaces. `usePhoneNumberField().phoneNumber` exposes E.164 for a possible number, otherwise
`null`; possibility is a length check, not proof of assignment or reachability.
`countries` restricts the list; the selected/default country must belong to it. With no initial value, the first allowed country is selected (US when unrestricted). Names
and sorting follow `locale`. Searching accepts country name, ISO code or calling code.
The sheet uses the existing BottomSheet and requires `react-native-gesture-handler` and
a `GestureHandlerRootView`, plus XAUIProvider's portal host.
**Country names need `Intl.DisplayNames`, which Hermes does not implement.** The field
does not throw without it — every country falls back to its own ISO code, so the list
reads `AD`, `CI`, `FR` and sorts that way, and flags, calling codes and search still work.
Install `@formatjs/intl-displaynames` and import it before your app's entry point to get
localized names on React Native.
## Props
Generated from TypeScript by `node tooling/component-docs/generate.mjs phone-number-field`.
The root inherits TextField's styles, states, ref and `asChild` behavior.
### PhoneNumberFieldProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ------------- | -------------------------------------------------- | ----------------------------------------------------------------------- |
| value | `PhoneNumberValue \| undefined` | |
| defaultValue | `PhoneNumberValue \| undefined` | |
| onValueChange | `((value: PhoneNumberValue) => void) \| undefined` | |
| locale | `string \| undefined` | Country names and their alphabetical order. |
| countries | `readonly CountryCode[] \| undefined` | Restrict the selectable countries. Defaults to all supported countries. |
### PhoneNumberFieldFieldProps
Inherited React Native and composed component props remain available.
### PhoneNumberFieldCountryProps
Inherited React Native and composed component props remain available.
### PhoneNumberFieldContentProps
Inherited React Native and composed component props remain available.
### PhoneNumberFieldSearchProps
Inherited React Native and composed component props remain available.
### PhoneNumberFieldCountryListProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
| renderCountry | `((country: PhoneCountryOption) => ReactNode) \| undefined` | Override a row's contents without replacing its selection behavior. |
## Slots
`Field` accepts TextField.Field props except the root-owned value and change handler.
`Country` accepts BottomSheet.Trigger props and belongs in FieldGroup.Prefix.
`Content`, `Overlay`, `Handle`, `Title` and `Close` reuse BottomSheet.
`Search` accepts TextField.Field props with its query owned by the phone root.
`CountryList` accepts FlatList props, including `ListEmptyComponent`, and an optional
`renderCountry` for row content. Labels, descriptions and errors reuse TextField slots.
Each styled node accepts its own style overrides; use `style` on the FlatList.
## Variants
The four TextField variants (`primary`, `secondary`, `tertiary`, `ghost`) and sizes
(`xs`, `sm`, `md`, `lg`) are inherited, including theme colors, focus and invalid states.
## Accessibility
The field is associated with its label and description. Give Country an action label in
your app's language; the calling code is exposed as its value. Selected country rows
announce their name, calling code and selected state. Label the search field and provide
an empty-state message. `isDisabled` prevents both editing and country selection.
## Migration from legacy
This is a new component. There is no legacy PhoneNumberField to deprecate.
# PieChart
The whole, and its parts.
```tsx
import { PieChart } from '@xaui/native/pie-chart'
4,5K
Appareils
```
The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md).
A ring is square rather than framed, so it draws its own geometry rather than sitting in the
shared plot.
## `innerRadius` is a fraction, not points
The hole has to stay in proportion at every size. A caller who writes 40 points gets a donut
on a phone and a pie on a tablet. `0` is the pie; the default is the donut.
## The middle is React Native
What sits in the hole is `children` in a `View` laid over the canvas — a `Text`, a number, an
icon — so it takes the theme's font and its scaling like everything else on the screen. SVG
text needs a font file loaded and ignores the platform's text size.
It takes no touches, so a press still reaches the slice under it.
## The gap is drawn, not stroked
Each slice is shortened by half the gap at either end, so the ring's ground shows through
between them. A stroke would sit **on top of** the slice and read as an outline.
A slice thinner than the gap keeps a hairline rather than being drawn inside out: a row with
a real value should not disappear because it is small.
A slice covering the whole circle is drawn as **two half arcs**, because an SVG arc from a
point back to itself is a no-op — a single-category pie would otherwise render as nothing at
all.
## Its own props
| Prop | Type | Default | Notes |
| ------------- | ------------ | ------- | -------------------------------------- |
| `data` | `Data[]` | — | One row per slice |
| `labelKey` | `keyof Data` | — | What names each slice |
| `valueKey` | `keyof Data` | — | The quantity |
| `innerRadius` | `number` | `0.62` | A fraction of the radius. `0` is a pie |
| `gap` | `number` | `1.5` | Degrees between slices |
| `children` | `ReactNode` | — | What sits in the hole |
A value of zero or below is dropped: a slice of less than nothing has no meaning in a pie,
and clamping is what keeps one from eating the ring in the other direction.
# Popover
A panel anchored to whatever opened it. The component the `Select` was written before: the
two share their positioning, their measuring pass and their entrance.
## Import
```tsx
import { Popover } from '@xaui/native/popover'
```
## Anatomy
```tsx
…
…
…
…
```
- **`Popover`** — state and resolved style. **It renders no node.**
- **`Popover.Trigger`** — what opens the panel, and the rectangle it anchors to.
- **`Popover.Overlay`** — the backdrop. Optional, and what closes the panel on a press
outside.
- **`Popover.Content`** — the panel, positioned against the trigger, in a portal.
- **`Popover.Title`** / **`Popover.Description`** — its two texts.
- **`Popover.Close`** — anything that closes it.
`Popover.Overlay` and `Popover.Content` render into the nearest `PortalHost` rather than
where they are written. Their place in the JSX says **when** they exist, not where they
appear.
## Usage
### Basic
```tsx
Détails
Livraison
Sous trois jours ouvrés.
```
`asChild` is the normal way to write the trigger. It paints nothing of its own — a
popover's trigger is usually a `Button`, an `Icon` or a word in a sentence, and giving it a
surface would put a second box around one of those.
### Placement
```tsx
```
`placement` takes all four sides, which is what separates this from `Select.Content`. A
select's list is as wide as the field it drops out of, and one hanging off the side of that
field reads as a menu; a popover belongs to nothing, so it can sit anywhere.
`align` runs along the axis the side does **not** pin — horizontal for a panel above or
below, vertical for one beside — and clamps to the screen insets.
`avoidCollisions` is on by default and flips to the opposite side, but only when that side
has strictly **more** room. A panel two points short stays where it was asked, because a
flip on a near miss reads as a glitch.
**Both axes are clamped to the insets**, whether or not it flips. The side decides where
the panel wants to go; the insets decide where it is allowed to be. A panel beside a
trigger with no room for it is pushed inside the screen and may overlap the trigger — which
is the right trade: a panel covering the button that opened it is legible, and a panel past
the edge of the screen is not.
### No backdrop
Omit `Popover.Overlay` and nothing captures the press outside: a `Popover.Close`, or your
own state, is what closes the panel. That is the answer for a popover inside a sheet that
already dims its background.
### Controlled
```tsx
const [isOpen, setOpen] = useState(false)
…
```
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `Popover`
| prop | type | default | description |
| -------------- | --------------------------- | ------- | ---------------------------- |
| `radius` | `RadiusKey` | — | Overrides the panel's corner |
| `isOpen` | `boolean` | — | Controlled |
| `defaultOpen` | `boolean` | `false` | Uncontrolled |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close |
| `isDisabled` | `boolean` | `false` | Dims the trigger, stops it |
There is no `variant`. A popover is the theme's floating surface: it has no emphasis to
report and no intent to carry, so a `variant` would name a decision nobody makes.
### `Popover.Content`
| prop | type | default |
| ----------------- | --------------------------------------- | ------------- |
| `placement` | `'top' \| 'bottom' \| 'start' \| 'end'` | `bottom` |
| `align` | `'start' \| 'center' \| 'end'` | `center` |
| `width` | `number \| 'trigger' \| 'content-fit'` | `content-fit` |
| `offset` | `number` | `9` |
| `alignOffset` | `number` | `0` |
| `avoidCollisions` | `boolean` | `true` |
| `insets` | `PopoverInsets` | `12` a side |
`width` defaults to `content-fit` where the `Select`'s defaults to `trigger`: matching the
width of a word or an icon would give the panel no room at all.
**`content-fit` stops at a measure, not at the screen.** Thirteen ems of the body size —
about twenty-six characters a line, narrow on purpose. A popover is read at a glance, and a
glance is two or three short lines rather than a paragraph; past this it stops being an
aside and starts being a sheet with a tail. A sentence always wants more room than it has,
so a panel bounded only by the edges is a full-width panel the moment it holds one.
`width="full"` is how you say "yes, actually, all of it" — the screen less its insets.
Nothing else in the union can: a number is a guess at the screen's width, and `content-fit`
refuses on purpose.
### `Popover.Trigger` · `Popover.Close`
Everything `Pressable` takes, plus `ViewStyle` as props, plus `asChild`.
### `Popover.Title` · `Popover.Description`
Everything `Text` takes, plus `TextStyle` as props. The title is a `header` for a screen
reader, so the panel announces as one.
## Motion
The panel grows out of the trigger: 200 ms in from `scale: 0.95`, offset eight points
**towards** it, so a panel below enters upwards, one above enters downwards, one beside
enters sideways. Exit mirrors it at 150 ms — closing is an acknowledgement rather than an
arrival, and a dismissal as long as the opening feels like the control is arguing.
**The measuring pass.** The panel mounts invisibly for one frame to learn how big it wants
to be, then places itself and plays its entrance. Without it `avoidCollisions` has nothing
to compare, and a panel that does not fit on the side it was asked for would open off the
screen. That frame is why the entrance is keyed on the resolved placement rather than
started at mount.
## What it shares with the `Select`
| shared | where |
| --------------------------------- | -------------------------------- |
| the placement arithmetic | `utils/placement.ts` |
| the trigger's measurement | `hooks/use-anchor-ref.ts` |
| the measuring pass and the origin | `hooks/use-anchored-position.ts` |
| the entrance and exit keyframes | `system/anchored/` |
Written for the `Select` first, extracted here — §2 bis: at the second use, never by
anticipation. `Menu`, `SubMenu` and `Tooltip` read the same four.
Two of them exist because of bugs rather than tidiness. The trigger measures again on
every open, because `onLayout` never fires on scroll and a trigger inside a `ScrollView`
otherwise reports where it used to be. And the position is computed in the **host's**
coordinates rather than the window's, because the trigger reports itself against the window
while the panel is laid out inside the `PortalHost` — anything the two differ by is exactly
the distance the panel would be wrong by.
## Accessibility
The trigger is a `button` carrying `expanded`. The overlay announces nothing at all — it is
the absence of the panel, and "button" spoken over the whole screen is worse than silence.
## The portal
`XAUIProvider` mounts the `PortalHost` these render into, so there is nothing to do.
Without a host anywhere, `Portal` renders nothing: the popover opens onto an empty screen
with no error. That silence is why the provider mounts one by default.
# ProgressBar
How far along something is.
## Import
```tsx
import { ProgressBar } from '@xaui/native/progress-bar'
```
## Anatomy
```tsx
```
- **`ProgressBar`** — the root. It clamps the value once, resolves the recipe once (R5) and
publishes both to its slots.
- **`ProgressBar.Header`** — the line above the rail, its two halves pushed apart. It exists
because R4 puts the gap and the alignment on a root rather than on the things being spaced.
- **`ProgressBar.Label`** — what is happening.
- **`ProgressBar.Value`** — how far along, as text. Formats itself.
- **`ProgressBar.Track`** — the rail: the whole of the distance.
- **`ProgressBar.Fill`** — the part of it that is behind you.
**The fill is a child of the rail, not a layer over it.** It grows to a percentage of the
width and the rail clips it, so one `radius` rounds both — an absolutely positioned overlay
would need a corner of its own and would get it wrong at 100%.
## There is no `isIndeterminate`
An unknown duration is a [`Spinner`](../spinner/spinner.md). That is the split the legacy
`Indicator` was two components pretending to be one, and a bar that runs a loop across
itself is a spinner drawn as a line.
This one reports a quantity, and a quantity it does not have is not a state it should be
able to be in.
## Usage
### R3 — the short form
```tsx
Téléchargement
```
A text child becomes the label, **and the bar comes with it**: a progress bar with no bar is
a line of text. The `Radio`'s rule, for the `Radio`'s reason.
Written with no children at all it is the rail alone, which is the form a list row wants:
```tsx
```
### The range
```tsx
```
`0` to `100` by default. Every argument is a caller's number, so all three are validated
once at the root: a value outside the range is clamped, an inverted or empty range reads as
empty, and anything that is not finite reads as zero. A fill of `NaN%` is something React
Native accepts and draws as nothing at all, with no error anywhere.
### The number beside it
```tsx
// 40 %
// 1 250,00 €
7 sur 12 // yours
```
`formatOptions` is `Intl.NumberFormat`'s, and **which number it formats follows the style**:
a percentage is a share of the range, so it formats the fraction; anything else is about the
quantity, so it formats the value. Formatting the fraction as euros would report a 1 250 €
goal as `0,63 €`.
A Hermes build compiled without ICU has no `Intl` at all; the fallback is the same number
with a plain percent sign rather than an exception.
The value is in `tabular-nums`, so a number ticking from 9 % to 10 % does not shift the
label beside it.
### The motion
The fill sweeps to each new width over 240ms. A bar that jumped would render a download
reporting every 5% as twenty still frames.
```tsx
```
`false` snaps — for a value the caller is already animating itself.
## Sizes
| `size` | Rail | Header type |
| ------ | ---- | ----------- |
| `sm` | 4 | 14/20 |
| `md` | 6 | 16/24 |
| `lg` | 8 | 18/28 |
**`size` is the rail's thickness, never its width.** A bar's length is its parent's, exactly
as a `Button`'s is — which is why there is no `fullWidth` and why the root spans by default.
The rail's numbers are off the spacing grid on purpose, like the `Slider`'s: how thin a line
can be and still read as a bar has nothing to do with the gaps between things.
`radius` is `full` on both the rail and the fill, and it moves both — a squared-off rail
holding a rounded fill is the thing the axis exists to prevent.
## Variants and colour
| `variant` | Rail | Fill |
| ----------- | --------- | ------------ |
| `primary` | `default` | `accent` |
| `secondary` | `default` | `foreground` |
| `success` | `default` | `success` |
| `warning` | `default` | `warning` |
| `danger` | `default` | `danger` |
Five of the ten, and the five that are left out say what this is. `tertiary` and `ghost` are
gone because a fill with no fill is not a progress bar; the `*-soft` pairs are gone because
the rail already is the soft half of every one of these, and a soft fill on a soft track is
one bar you cannot read.
The rail is the same neutral under all five: it is the room left to go, and that is not
success, warning or danger.
`color` is a raw value (R7) and lands on the fill, through the `bgSelected` role — the
`Checkbox`'s pair, meaning the same thing on a line instead of in a box. Why a role and not
an axis is written up once in [`checkbox.md`](../checkbox/checkbox.md#colour).
## Alignment with the reference implementation
**Identical:** the three sizes, the label-and-value header, the clamped range with
`minValue` / `maxValue`, `formatOptions` through `Intl`, and Track · Fill as slots.
**Three deltas:**
| Theirs | Ours | Why |
| ------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `color="success"` | `variant="success"` | Their `color` is our `variant`: an enum of intents is the design system's vocabulary, and `color` here is a raw tint (R7). |
| `isIndeterminate` | `Spinner` | Two components pretending to be one. A bar with no quantity is a spinner drawn as a line. |
| `TrackBackground` under `Track` | `Track` alone | Two nodes for one rail. The rail _is_ the background; the second one existed to be themed separately, which `style` on the slot does. |
## Props
### `ProgressBar`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| --------------- | -------------------------- | ----------- | ------------------------------------- |
| `variant` | `ProgressBarVariant` | `'primary'` | The fill's colour |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The rail's thickness. Never its width |
| `radius` | `RadiusKey` | `'full'` | The rail and the fill together |
| `color` | `string` | — | A hex tint, on the fill |
| `value` | `number` | `0` | Clamped into the range |
| `minValue` | `number` | `0` | |
| `maxValue` | `number` | `100` | |
| `formatOptions` | `Intl.NumberFormatOptions` | percent | What `Value` reads |
| `isDisabled` | `boolean` | `false` | Dims it. There is nothing to press |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `ProgressBar.Fill`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | --------- | ------- | ------------------------------ |
| `animation` | `boolean` | `true` | `false` snaps to the new width |
### `ProgressBar.Header`, `.Track`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14).
### `ProgressBar.Label`, `.Value`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14). `Value`'s children
replace the formatted number outright.
## Extending it
`useProgressBar()` is exported (R10) and carries the resolved styles plus `fraction`,
`value`, `formatOptions` and `isDisabled` — enough to write a second line under the bar, an
estimate beside it, or a mark at the point it has to reach, without recomputing a fraction
the root already clamped. Outside a `` it throws by name.
## Accessibility
- `accessibilityRole="progressbar"` on the root, overridable.
- `accessibilityValue` carries the **caller's** range and value — `{ min, max, now }` — not
the clamped fraction: "40 sur 100" is what the caller wrote, and rounding it to a
percentage here would be answering a question nobody asked.
- The label is a `Text` inside the root, so it is read with the value rather than beside it.
# ProgressCircle
How far along something is, drawn as a ring.
## Import
```tsx
import { ProgressCircle } from '@xaui/native/progress-circle'
```
## Anatomy
```tsx
```
- **`ProgressCircle`** — the root. It clamps the value, resolves the recipe and computes the
ring's geometry once, then publishes all three.
- **`ProgressCircle.Indicator`** — the ring: the track, and the arc of it that is done.
- **`ProgressCircle.Value`** — how far along, written in the middle.
Written with no children it is the ring alone, which is the form a row wants.
**It is the [`ProgressBar`](../progress-bar/progress-bar.md) bent into a circle** — the same
five variants, the same clamped range, the same `formatOptions`, the same 240ms — and it is
a second component rather than a `shape` prop on that one because the two share no geometry
at all: a bar is a `View` that grows, and this is an SVG path whose dash offset moves.
There is no `isIndeterminate` here either. The reason is the bar's, and it is written up
[there](../progress-bar/progress-bar.md#there-is-no-isindeterminate).
## `radius` is a number here
```tsx
```
**The one place in this library where `radius` means what it means in geometry**, because a
circle has no corner to round. It is a raw number, so it lives outside the style cache and
outside the vocabulary (R6): it wins over `size` exactly as a raw `color` wins over a
variant's token.
Reach for it when the ring has to line up with something already on the screen — an avatar,
a row's height. Reach for `size` otherwise: the ladder is the vocabulary.
`strokeWidth` is the same kind of prop, and both are clamped. A stroke thicker than the ring
is wide would draw a path with a negative radius, which renders nothing, on one platform,
with no error.
## Usage
### The number in the middle
```tsx
✓
```
`Value` is positioned absolutely so it centres on the ring rather than pushing it: the root
is a box the size of the circle, and a text node in the flow would make that box taller than
the drawing in it.
It formats through the root's `formatOptions`, on the same rule as the bar's — the fraction
for a percentage, the value for anything else. Children replace it outright.
**Not at `sm`.** 32 points across, less two strokes, is narrower than "64 %" is wide. That
step is the ring on its own.
### The motion
The arc sweeps to each new length over 240ms — the bar's number, because two progress
indicators on one screen moving at two speeds is a bug nobody files and everybody sees.
It is a **dash offset on one path** rather than a shape rebuilt per value, which is what
keeps one rounded cap at each end while it moves, and it is an animated _prop_ rather than
an animated style, because `strokeDashoffset` is an SVG attribute.
```tsx
```
The ring starts at twelve o'clock. The turn is on the indicator's wrapper rather than on
each path: `Circle`'s own `originX` / `originY` / `rotation` emit an invalid DOM property on
web, and one rotation on the box is one thing to read instead of three props on two circles.
## `react-native-svg`
An arc with a rounded cap is a stroked path, so `ProgressCircle.Indicator` imports
`react-native-svg` — an **optional peer**. The alternative in plain views is two rotated
half-discs clipped by a third, which cannot round its own ends.
Only that one file imports it, and this component is its own subpath export
(`@xaui/native/progress-circle`), so a project that never renders a ring never pays for it.
The `Select`'s check and the `Icon`'s chevron are already in the same position.
## Sizes
| `size` | Diameter | Stroke | Value type |
| ------ | -------- | ------ | ---------- |
| `sm` | 32 | 3 | 12/16 |
| `md` | 48 | 4 | 14/20 |
| `lg` | 64 | 5 | 16/24 |
Bigger than the [`Spinner`](../spinner/spinner.md)'s ladder at every step, and that is the
difference between the two: a spinner says "wait" and this says how long, which usually
means it has a number written in the middle of it.
The stroke is centred on the path, so the ring fits its box: the path's radius is half the
diameter less half the stroke.
## Variants and colour
The `ProgressBar`'s table, unchanged — one neutral ring, and five arcs on it. `color` is a
raw value (R7) and lands on the arc through the same `bgSelected` role.
The two colours reach the SVG as **strings**, not styles: a path is stroked by a prop rather
than by a stylesheet. The recipe still owns them, on two slots it never renders — the same
thing the `Tabs` recipe does with its `content` slot — because that is what makes a raw
`color` reach the arc through `resolveTint`, which only maps roles a variant declared.
## Alignment with the reference implementation
**Identical:** the three sizes, the clamped range, `formatOptions`, the SVG ring with a
rounded cap starting at twelve o'clock, and Indicator · Value as slots.
**Three deltas:**
| Theirs | Ours | Why |
| ----------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `size={40}` — the ladder takes a number | `size` is tokens, `radius` is the number | R6: a vocabulary prop takes tokens or it is not a vocabulary. The raw escape hatch gets its own name, like `color` does. |
| `strokeWidth` / `trackColor` / `fillColor` on the Indicator | `strokeWidth` on the root, colours from the recipe | R1 — colours are the recipe's, and `variant` plus `color` is the way in. |
| `color="success"` | `variant="success"` | Their `color` is our `variant`, as on the bar. |
## Props
### `ProgressCircle`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| --------------- | -------------------------- | ----------- | --------------------------------------------- |
| `variant` | `ProgressCircleVariant` | `'primary'` | The arc's colour |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Diameter, stroke, and the type inside |
| `radius` | `number` | — | The ring's radius in points. Wins over `size` |
| `strokeWidth` | `number` | — | Clamped to half the diameter |
| `color` | `string` | — | A hex tint, on the arc |
| `value` | `number` | `0` | Clamped into the range |
| `minValue` | `number` | `0` | |
| `maxValue` | `number` | `100` | |
| `formatOptions` | `Intl.NumberFormatOptions` | percent | What `Value` reads |
| `isDisabled` | `boolean` | `false` | Dims it. There is nothing to press |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `ProgressCircle.Indicator`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | --------- | ------- | ------------------------------- |
| `animation` | `boolean` | `true` | `false` snaps to the new length |
### `ProgressCircle.Value`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14). Children replace the
formatted number.
## Extending it
`useProgressCircle()` is exported (R10) and carries the resolved geometry, the two stroke
colours, the fraction and the value — enough to draw a second arc, a mark at the target, or
an icon in place of the number. Outside a `` it throws by name.
## Accessibility
- `accessibilityRole="progressbar"` on the root, overridable.
- `accessibilityValue` carries the caller's range and value, not the clamped fraction.
- The ring itself is a drawing; what a screen reader reads is that value.
# RadarChart
Several quantities at once, each on its own axis.
```tsx
import { RadarChart } from '@xaui/native/radar-chart'
;
```
The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md).
## The data is transposed
**A row here is an axis, not a reading along one**, and it is the one thing to get right at
the call site. That is what a radar is: several quantities compared across the same set of
measures. Reading it the other way round produces a chart with one axis per month, which is
a line chart drawn in a circle.
## The edges are always straight
The vertices **are** the axes. A curve between two of them would draw a reading on an axis
that does not exist, which is why nothing here takes a `curve`.
## `maxValue` is what makes two radars comparable
Unset, the web's edge is the highest reading in this chart — so its shape says how the
measures compare _within_ it, and nothing at all about any other chart. A dashboard of radars
should give all of them the same top.
Every axis shares one scale, always. A radar whose axes were scaled apart would be several
charts drawn on top of each other.
## Its own props
| Prop | Type | Default | Notes |
| ------------- | ---------------- | ---------- | -------------------------------------- |
| `data` | `Data[]` | — | One row per **axis** |
| `axisKey` | `keyof Data` | — | What names each axis |
| `yKeys` | `(keyof Data)[]` | — | The series |
| `levels` | `number` | `4` | Rings — the chart's only scale |
| `maxValue` | `number` | the data's | The top of every axis |
| `hasLabels` | `boolean` | `true` | `false` hands the whole box to the web |
| `fillOpacity` | `number` | `0.18` | |
| `strokeWidth` | `number` | `2` | |
| `hasPoints` | `boolean` | `false` | A dot at every vertex |
Fewer than three axes draws nothing: two axes is a line, and one is a point.
The labels sit just outside the outermost ring, in a fixed box pulled back by half its width
— the only placement that works at every angle without measuring the text first. `hasLabels`
is what a radar used as a glyph rather than as a figure turns off, and it hands the room back
to the web.
# RadialChart
Several quantities, each as far round its own ring as it has got.
```tsx
import { RadialChart } from '@xaui/native/radial-chart'
Calories
700 kcal
```
The palette, the variants and the ink are the family's — [`chart.md`](../chart/chart.md).
Like the [`PieChart`](../pie-chart/pie-chart.md) and the
[`RadarChart`](../radar-chart/radar-chart.md), it is square rather than framed, so it draws
its own geometry rather than sitting in the shared plot.
## A ring is not a slice
The `PieChart` splits **one** quantity into shares that add up to the whole. This draws
**several** quantities that have nothing to do with each other, each against a target of its
own.
Calories, steps and minutes do not sum to anything, and a donut of the three would be
drawing a total nobody measured. That is the whole distinction, and it is the one to get
right at the call site.
## The first row is the outermost ring
And the palette walks the rows in that order, so the ring that catches the eye first is the
row written first.
## Each ring has its own target
| what you have | what you write |
| ------------------------ | ------------------------------------------- |
| a goal per row | `maxKey="target"` |
| one goal for all of them | `maxValue={2000}` |
| no goal at all | neither — the largest value becomes the top |
`maxKey` wins wherever a row carries one, so a chart can mix a per-row goal with a shared
fallback.
With neither, the biggest ring closes and the rest are read against it. That makes a figure
honest about itself and says nothing about any other — the `RadarChart`'s `maxValue` rule,
for the same reason: a dashboard of radial charts should give all of them the same top.
A reading of zero or below draws no arc: a ring runs from nothing to its target, and a
negative value would ask for a dash offset longer than the path.
## The track is the rest of the distance
Without it a ring at a fifth is an arc floating in space, with nothing saying how far it had
to go — which is the one thing this figure exists to say. `hasTrack={false}` gives it up for
a figure that is a glyph rather than a chart.
Every track is drawn before every ring, so a ring's rounded cap is never cut by the ground
of the one inside it.
## The rings thin rather than disappear
The stroke is centred on the path, so a ring drawn at the box's own radius loses its outer
half to the canvas edge — and six series at the default thickness ask for more room than a
phone-sized figure has.
So the geometry is **clamped to the room there actually is**: the gap takes at most half of
it, and the thickness takes what is left, divided evenly. What gives is the thickness,
because the alternative is a chart that silently drops its innermost rings.
Every ring shares one thickness. Rings of different weights read as different charts.
## The middle is React Native
The `PieChart`'s arrangement, for the `PieChart`'s reason: what sits in the middle is
`children` in a `View` laid over the canvas — a `Text`, a total, an icon — so it takes the
theme's font and its scaling like everything else on the screen. It takes no touches.
The canvas is turned a quarter turn on its **wrapper** rather than on each circle, which is
`ProgressCircle`'s arrangement and its reason: `Circle`'s own `rotation` prop emits an
invalid DOM property on web. It also leaves the middle upright, since that is the wrapper's
sibling rather than its child.
## Its own props
| Prop | Type | Default | Notes |
| ----------- | ------------ | ------------------ | -------------------------------------------- |
| `data` | `Data[]` | — | One row per ring, the first outermost |
| `labelKey` | `keyof Data` | — | What names each ring |
| `valueKey` | `keyof Data` | — | The quantity |
| `maxKey` | `keyof Data` | — | That row's own target |
| `maxValue` | `number` | the largest value | One target for every ring |
| `thickness` | `number` | the size's own | Points, raw — it wins over `size` |
| `gap` | `number` | half the thickness | Points of bare ground between two rings |
| `hasTrack` | `boolean` | `true` | The whole of each ring's distance, behind it |
| `children` | `ReactNode` | — | What sits in the middle |
## In a `Chart`
The frame carries the words and the legend, and its `seriesCount` is what walks the legend's
dots to the same length as the rings:
```tsx
Activité du jour
```
# Radio
One option out of a set, and the label that says which.
## Import
```tsx
import { Radio } from '@xaui/native/radio'
```
## Anatomy
```tsx
```
- **`Radio`** — the root, and it is the **row, not the circle**. It is the pressable: it
owns the selected state, resolves the recipe once (R5) and publishes it to its slots.
- **`Radio.Indicator`** — the circle, and the dot inside it. With no children it draws its
own dot.
- **`Radio.Label`** — what choosing this option means.
- **`Radio.Group`** — the set, and the one option in it that is chosen. Optional: a radio
over its own `isSelected` works without one.
**It is the [`Checkbox`](../checkbox/checkbox.md) in a circle, with one rule changed: a
press selects, it never clears.** A set of options has no "none of these" unless one of
them says so, and a radio you could tap back to empty would be a checkbox that happens to
be round. `onSelectedChange` therefore only ever fires with `true`, and pressing the chosen
option fires nothing at all.
Everything else is the checkbox's, deliberately: the root is the row so the label chooses
the option, R3 wraps a text child into the label _and_ supplies the circle, the three
variants and the four sizes are the same tokens, and no slot carries a margin (R4).
## The set
```tsx
const [plan, setPlan] = useState('monthly')
Tous les mois
Tous les ans — deux mois offerts
À vie
```
**`Radio.Group` is the context this component was written to read, not a second radio.** An
option still owns its circle, its press and its recipe; what it could not know alone is
whether it is the chosen one, and that is the whole of what the group publishes.
**Exclusivity comes from comparing one value, not from talking to siblings.** The group
holds the chosen `value`, each option compares the `value` it stands for, and nothing walks
the children — so an option nested in a `Card`, a `List.Item` or a `Fragment` is in the set
exactly as much as a direct child is:
```tsx
Carte bancaire
Virement
```
An option with **no `value`** is not in the set, whatever it is nested in — which is what
keeps a standalone radio over its own `isSelected` working unchanged inside one.
### What the set hands down
`variant`, `size`, `radius` and `color` are **defaults**, and an option that names its own
wins: a set is usually uniform, and the row that differs is a design rather than a mistake.
`isDisabled` and `isInvalid` are the two that do not work that way — a disabled set has no
enabled option in it, and a set that is wrong is wrong on every row. An option can still be
disabled on its own inside an enabled set; it cannot opt back into a disabled one.
```tsx
Toutes en lg, violettes et éteintes
Sauf le contour, qui est à elle
```
### Orientation
```tsx
…
```
The set lays its options out — that is R4, and the reason `Radio.Group` has a recipe at all.
`vertical` is the default; `horizontal` wraps, so three short labels stay one row on a phone
and become two on a narrow screen rather than overflowing off it. The gap follows `size`.
### Uncontrolled
```tsx
…
```
`value` present means the caller owns the choice; `defaultValue` leaves it with the group.
`onValueChange` fires either way, and never with `undefined`: a press selects and never
clears, so a set that has a chosen option keeps one.
## Usage
### Controlled, or not
```tsx
setPlan('yearly')}>
Tous les ans — deux mois offerts
Choisie au départ
```
`isSelected` present means the caller owns the value; `defaultSelected` is for the
standalone case. Inside a `Radio.Group` neither is needed — the set is the controlled source
— and `isSelected` still outranks it, which is what lets one option in a group be driven by
something the group knows nothing about. `onPress` fires either way, composed with the
selection: it is what a wrapper listens to when it wants the press rather than the change.
### Invalid
```tsx
Aucune option choisie
```
The border, the fill and the label turn `danger`, and the resting fill is dropped: an
option that is wrong reads as an outline. On a set, put it on the **group** — the error
belongs to the question, not to one of its answers.
### Disabled
```tsx
Indisponible
```
On the row, not the circle: what is disabled is the control, and the label is part of it.
### A mark of your own
```tsx
```
Children replace the built-in dot and ride the same fade.
### As another element
```tsx
…
```
R12 — the caller's element **is** the row, so it takes the children it was written with and
the auto-wrap does not apply.
## Sizes
| `size` | Circle | Dot | Gap | Label |
| ------ | ------ | --- | --- | ----- |
| `sm` | 20 | 8 | 8 | 14/20 |
| `md` | 24 | 10 | 8 | 16/24 |
| `lg` | 28 | 12 | 10 | 18/28 |
The same four boxes as the `Checkbox`, so a radio and a checkbox in one form line up. The
dot keeps the reference implementation's ratio — 10 in 24 — rather than being tabulated, so it is one dot at four
sizes instead of four drawings of one.
`radius` is `full` and overridable: a squared-off option in a segmented row is a real
design, and every control in this library takes the same prop.
## Variants and colour
The `Checkbox`'s three levels, on the same `field*` tokens:
| `variant` | Circle at rest | Border | Shadow |
| ----------- | ----------------- | ------------- | ------- |
| `primary` | `fieldBackground` | `fieldBorder` | `field` |
| `secondary` | `default` | `fieldBorder` | — |
| `tertiary` | transparent | `fieldBorder` | — |
A variant describes the circle **at rest**. Chosen, all three are the accent — or `color`,
which lands on the fill through the `bgSelected` role, with the dot on `fgSelected`. The
tint is ignored while `isInvalid`, as on the `Checkbox`.
Why a role and not an axis is written up once, in
[`checkbox.md`](../checkbox/checkbox.md#colour), and it holds here unchanged.
## Alignment with the reference implementation
Measured against their `radio.tsx` and `radio.css`.
**Identical:** the 24pt circle, the field border and its width, the `field` fill plus shadow
on `primary` and the neutral `default` on `secondary`, the accent fill with an
`accentForeground` dot at 10/24 of the circle, the danger treatment including the dropped
resting fill, and a built-in dot so no icon set is required.
**Four deltas:**
| Theirs | Ours | Why |
| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| The root is the circle; a label needs `ControlField` | The root is the row, `Label` is a slot | The plan's slots are Indicator · Label, and a label that does not choose the option is a bug waiting to be filed. |
| `Radio.IndicatorThumb` is a part | The dot is the indicator's default child | Two nodes to write for one dot. Children replace it, which is the same escape hatch with one component fewer. |
| The thumb scales 1.5 → 1 on its own | The fill and the dot arrive together | One `SelectionFill`, shared with the `Checkbox`: what differs between the two controls is the mark, not how it arrives. |
| `Radio.IndicatorBackground` (a glass layer) | — | A theme-registered blur layer belongs to their Uniwind theming, which is the premise XAUI does not share. |
Their `RadioGroup` is a component of its own with a `RadioGroup.Item` inside it; ours is
`Radio.Group` around plain `Radio`s. One import, one option component, and a radio that
means the same thing whether or not it is in a set — theirs has two, and the standalone one
is the one that cannot be grouped.
## Props
### `Radio`
Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim
(R14), plus:
| Prop | Type | Default | Notes |
| ------------------ | ------------------------------- | ------------- | --------------------------------------- |
| `variant` | `RadioVariant` | `'secondary'` | The circle at rest |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Circle, dot, gap, label type |
| `radius` | `RadiusKey` | `'full'` | A circle, unless you say otherwise |
| `color` | `string` | — | A hex tint — the colour once chosen |
| `value` | `string` | — | What it stands for in a `Radio.Group` |
| `isSelected` | `boolean` | — | Controlled, and it outranks the group |
| `defaultSelected` | `boolean` | `false` | Uncontrolled starting value |
| `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires with `true` only |
| `isInvalid` | `boolean` | `false` | Danger colours, and the tint is ignored |
| `isDisabled` | `boolean` | `false` | Dims the row and stops the press |
| `animation` | `AnimationProp` | — | The press treatment, as everywhere else |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `Radio.Group`
Everything `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| --------------- | ---------------------------- | ------------ | ------------------------------------- |
| `value` | `string` | — | The chosen option. Controlled |
| `defaultValue` | `string` | — | Uncontrolled starting choice |
| `onValueChange` | `(value: string) => void` | — | Never fires with `undefined` |
| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | `horizontal` wraps |
| `variant` | `RadioVariant` | — | Default for every option |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | The options' scale, and the gap |
| `radius` | `RadiusKey` | — | Default for every option |
| `color` | `string` | — | Default for every option |
| `isDisabled` | `boolean` | `false` | Every option, and no row opts back in |
| `isInvalid` | `boolean` | `false` | Every option — the question is wrong |
| `asChild` | `boolean` | `false` | Merge into the single child |
### `Radio.Indicator`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | ----------- | ------- | ----------------------------------------------- |
| `children` | `ReactNode` | — | Replaces the built-in dot, inside the fill |
| `animation` | `boolean` | `true` | `false` shows the dot with no fade and no scale |
### `Radio.Label`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14).
## Extending it
`useRadio()` is exported (R10) and carries the resolved styles plus `isSelected`,
`isDisabled` and `isInvalid` — enough to write a description under the label or a price
beside it. Outside a `` it throws by name.
`useRadioGroup()` is exported too, and carries the chosen `value`, `select`, and the
appearance the set hands down — enough to write an option of your own that is in the set
without being a `Radio`. Outside a `` it throws by name.
## Accessibility
- `accessibilityRole="radio"` on the root, overridable, with `accessibilityState.checked`
following the value and `.disabled` following `isDisabled`. A caller's own
`accessibilityState` is **merged**, not spread over.
- `aria-invalid` follows `isInvalid`.
- `accessibilityRole="radiogroup"` on `Radio.Group`, overridable, so a screen reader
announces "2 sur 3" rather than three unrelated controls. A set built without the group
needs that role on whatever wraps it.
- The whole row is the touch target, which is what makes a 24pt circle reachable.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| ------------------------------ | ------------------------------------------------------------------ |
| `label="…"` | `… ` — the text child _is_ the label |
| `labelAlignment="left"` | write `` before `` |
| `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props |
| `isChecked` / `defaultChecked` | `isSelected` / `defaultSelected` |
| `onValueChange` | `onSelectedChange` — and it fires with `true` only |
| `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) |
| `variant="filled"` | `variant="tertiary"` — a border at rest, the accent once chosen |
| `variant="light"` | gone: a dot with no circle. `` with your own mark |
| `size="sm" \| "md" \| "lg"` | `size` — the same three |
| `radius` | `radius` — a `RadiusKey` now, and still `full` by default |
| `fullWidth` | `width="100%"` in style props (R14) |
| `isDisabled` | unchanged, on the root |
| `labelStyle` | `style` on `` |
| `style` | `style` on the root |
| `` | `` — same `value` / `defaultValue` / `onValueChange` |
The legacy `RadioGroup` also carried `orientation` — unchanged — and the shared `variant` /
`size` / `radius` / `themeColor` / `labelAlignment` / `fullWidth` for its options. The first
three are handed down as before, `themeColor` becomes `color` with a raw value (R7), and the
last two are gone with the props they mirror: `labelAlignment` is JSX order plus a style
prop on the row, and `fullWidth` is `width="100%"`.
One difference worth reading twice: the legacy group **rendered** its options from an array
of items, ours takes them as children. A set of options that comes from data is a `map`, and
it is the same `map` the rest of this library asks for.
# RangeCalendar
A month, and the period chosen in it.
## Import
```tsx
import { RangeCalendar } from '@xaui/native/range-calendar'
```
## Usage
```tsx
```
## It is a `Calendar`
The root is the `Calendar`'s root, unchanged — the same variants, the same `size`, `radius`
and `color`, the same month state, the same bounds, the same weekday names.
`Header`, `Title`, `PreviousButton`, `NextButton` and `Weekdays` **are** its slots,
re-exported rather than wrapped. Only the day cell differs, and only by having a band behind
it.
That is possible because **`Calendar.Grid` takes a function child**: forty-two cells are
generated rather than written, so replacing the cell is the one composition point that
component published — and this is what it published it for.
The `Calendar` below holds **no chosen day of its own**: a range has two ends and its `value`
has room for one, so the cells paint themselves off this component's context instead.
## Three presses, not two
| the state | pressing a day |
| ---------------- | -------------------------------------------- |
| nothing chosen | it becomes the start |
| a start, no end | on or after it → it becomes the end |
| a start, no end | **before** it → it becomes the new **start** |
| both ends chosen | a **new** range starts from it |
**A range already chosen starts a new one.** Asking a reader to clear first is asking them to
find a control that should not need to exist.
**A day before the start becomes the start**, not an end that precedes it. A backwards range
is not a range, and silently swapping the two would move a bound the reader did not touch.
**A one-day range is allowed.** A one-night stay and a one-day event are real, and a picker
that cannot express them is one a caller has to work around. Its cell draws as a single mark
with **no band** — a strip under one cell would read as a range somehow wider than the day it
contains.
Every end is taken to midnight, for `startOfDay`'s reason: a range whose ends carry the
moment they were pressed compares unequal to the same two days written by the caller.
## The band
Three slots and no more: the cell, the type, the muted day, the today dot and the chosen day
are all the `Calendar`'s. A day in a range is one of its cells with a band behind it.
The band is **out of flow, and exactly as wide as its cell**. Out of flow so nothing about the
day's own layout moves when it appears; exactly as wide because a soft token is translucent —
`accentSoft` is the accent at fifteen percent — and a band overhanging into its neighbour is
painted twice along the overlap, which reads as a rule down every seam rather than as one
unbroken strip. Abutting is safe: Yoga rounds a node's leading and trailing edges to the pixel
grid independently, so two adjacent cells share the boundary they meet on.
The two ends **stop at the middle of their own cell**, which is where the chosen day's circle
is — `start` and `end`, never left and right (R13). A cell is a seventh of the row and the
circle in it is a fixed square, so a cap drawn to the cell's edge runs four or five points
past the day it belongs to and the period reads as wider than the two days that bound it.
From the centre the cap is hidden under the circle and emerges level with its edge, which is
also why it carries no radius of its own: the strip's round ends are the two circles.
`color` reaches the band through `bgSelected`, the same role the chosen ends take, so a tinted
range is tinted throughout.
## See also
- **`Calendar`** — one day rather than two.
- **`DateRangePicker`** — this month behind a field.
# Rating
A row of marks, given or shown.
## Import
```tsx
import { Rating } from '@xaui/native/rating'
```
## Usage
```tsx
```
## One component for the input and the average
The fill of each mark is a **fraction**, not a boolean, so a value of 4.3 shows three tenths
of the fifth mark. A boolean per mark would have had to round it — and rounding is exactly
what an average must not do.
`precision` governs **input only**: it is the step a press lands on, and a `value` of any
precision displays as it is whatever that says.
## The row is data, not markup
`max` says how many marks there are, so the root renders them. Five hand-written marks is a
row that disagrees with the prop the moment either changes.
`children` is therefore the **glyph** — written once, drawn once per mark:
```tsx
```
## A mark is the same glyph twice
The neutral one sits in the flow and decides how wide the mark is. The filled one is pinned
over it inside a clip whose width is the fraction given, so the glyph is cut part-way through
rather than swapped for a different one.
Which layer an instance is in comes from the **layer**, not from a prop, which is what lets
the glyph be written once: `Rating.Icon` reads its own colour from where it was placed. A
`tone` prop would have made the caller write the glyph twice and keep the two in step.
An empty mark mounts one glyph rather than two — a clip of width zero is a node per mark for
every mark not yet given.
### The default mark is `★` in both layers
Never a hollow `☆` under a solid one. A solid pair is what every store's rating looks like,
and `☆` (U+2606) is not in Android's system face, so the empty half of the row would render as
a box on the platform where most of it is read. `★` (U+2605) is present on both.
It is a character rather than an SVG, so the component needs no `react-native-svg` for the one
glyph it draws itself.
## The press reads where it landed
`locationX` over the mark's width is the fraction the finger meant, and it rounds **up** to
the nearest `precision` step. That is the only rounding that matches the gesture: a tap
anywhere in the first mark is one star rather than zero, and with `precision={0.5}` the left
half of the third mark is 2.5 while the right half is 3.
Rounding to nearest would make the first sliver of every mark select the one before it — so
the leftmost strip of the row would silently rate zero.
## Appearance
Three variants, naming which colour a **filled** mark takes: `primary` the accent,
`secondary` the neutral foreground, `tertiary` the page's own foreground for a rating on a
coloured card.
The amber every store uses is not a token, because it is not a role the theme has an opinion
about — it is `color="#f59e0b"`, which is what a raw tint is for (R7). `warning` is
deliberately not offered in its place: it is a status, and a four-star review is not a
warning.
The unfilled marks read the **neutral fill** token rather than a faint version of the filled
colour. A pale tint of the accent under a row of stars looks like a control that half failed
to load; a neutral one looks like a mark that is simply not given yet. It is the same choice
the `Carousel`'s inactive dots make, and it is why a raw `color` reaches the filled marks
only.
| `size` | mark | gap |
| ------ | ---- | --- |
| `xs` | 14 | 2 |
| `sm` | 16 | 2 |
| `md` | 20 | 4 |
| `lg` | 24 | 4 |
**No width in the recipe.** A mark is as wide as the glyph in it, which is what keeps a star
and a heart the same row without either being told how wide the other is — and the mark's
width is its type size, because a glyph is text and the clip over it has to be measured in the
unit the glyph was set in.
The two layers take the same size. A fill layer one point off would show a rim of the layer
beneath it along every edge.
## What is not here
**No label.** A "4,3 out of 5" beside the row is a `Typography` in a `Row` — the screen's,
not the rating's (R1). The moment it were a slot it would need an alignment, a format and a
language.
**No `isClearable`.** Tapping the mark you already chose to go back to nothing is a gesture
half the world's rating inputs have and half do not, so it is not a default either way. A
caller who wants it holds the value and compares, which is three words and says which
behaviour they picked:
```tsx
setScore(next === score ? 0 : next)} />
```
## Accessibility
The marks carry the role, and which role depends on the job: `button` when it is an input,
`image` when `isReadOnly` makes it a display — five buttons that do nothing is worse than one
picture that says the value, and the read-only row announces `"4.3 out of 5"` on the row
itself.
The root carries no role. A role there would have a screen reader describe the box that holds
the marks as a second thing.
A mark carries **no press feedback overlay**: the mark filling _is_ the answer to the press,
and a wash over a glyph that is already changing colour reads as two things happening to it.
## See also
- **`Slider`** — a continuous value where this is a small number of discrete marks.
# Scaffold
The app's chrome, painted from the theme: the ground under every screen, the status bar
over it, and the options the navigator is dressed with.
## Import
```tsx
import { Scaffold } from '@xaui/native/scaffold'
```
## Usage
```tsx
export default function RootLayout() {
return (
}}>
)
}
```
It has to be **under** `XAUIProvider`, which is what resolves the theme it reads.
## It depends on no navigator
`@xaui/native` has no dependency on `expo-router`, on `@react-navigation/native` or on
`expo-status-bar` — not a real one, not an optional peer, not a type import. This component
does not change that.
`Scaffold.Navigator` takes the app's own navigator **as its child** and clones it with five
style keys merged into its `screenOptions`. Those keys are React Navigation's spelling,
declared structurally here as `ScaffoldScreenOptions`, and the navigator is the thing that
knows what to do with them. So a `Stack`, a `Tabs`, a `Drawer`, Expo Router or React
Navigation on its own are all dressed by the same component, and the library imports none
of them.
**Routing stays entirely the app's.** Nothing here writes a route, wraps a screen or
touches what the navigator was configured with: `initialRouteName`, the `Screen` children,
a nested layout and the navigator's own type all pass through untouched.
## A ladder, not four emphases
| variant | header fill | header edge | title ink |
| ----------- | ------------ | ----------- | ------------------- |
| `primary` | `accent` | — | `accentForeground` |
| `secondary` | `surface` | — | `surfaceForeground` |
| `tertiary` | `background` | `border` | `foreground` |
| `ghost` | `background` | — | `foreground` |
It says **how much the header separates from the page**, from a bar you cannot miss down to
no bar at all. `ghost` is the default: a header painted in the page's own colour, which is
the arrangement almost every app now wants.
The page's ground is the theme's `background` under every variant. A scaffold that
repainted the page per variant would be a theme rather than a chrome — the variant is about
the bar.
There is no `success` / `warning` / `danger`: chrome reports nothing.
### The tint follows the variant
`color` is a raw value (R7), and it lands where the variant put its tokens, as everywhere
else in the library:
- `primary` and `secondary` — the **bar**, with contrasted ink derived in OKLab.
- `tertiary` — the **title and the hairline**, on the page's own ground.
- `ghost` — the **title** alone.
That is why the two flat variants name no fill in the recipe. A ghost button paints its
label in the tint because it declared no background to take it, and this is the same
declaration: `` is a brand title on the page's ground, not a
violet page.
## Slots
| slot | renders | notes |
| -------------------- | --------------------------- | ---------------------------------------- |
| `Scaffold.StatusBar` | React Native's `StatusBar` | The ink is the **opposite** of the mode |
| `Scaffold.Navigator` | the app's navigator, cloned | Exactly one element, and it stays itself |
`Scaffold.StatusBar` takes every `StatusBarProps`, and what you write wins over what was
resolved — `translucent` and `hidden` are the app's call, and nothing about them is in the
theme. An app that prefers `expo-status-bar` reads `barContent` off `useAppearance` and
renders it itself; the one-word spelling is there for exactly that.
`Scaffold.Navigator` has **no `asChild`**. The prop distinguishes "render yourself" from
"dress my element", and this slot has only the second mode — there is no navigator XAUI
could render in place of the app's. Give it anything but a single element and it throws by
name.
### The merge gives the app the last word
The theme's options go **under** the navigator's own, key by key, and the three style keys
blend rather than choose:
```tsx
// → headerStyle: { backgroundColor: , height: 96 }
```
That is what `mergeScreenOptions` exists for: `mergeProps` would hand the app's
`headerStyle` the whole key, and a navigator asked for a taller header would lose its
ground with it. The function form of `screenOptions` is preserved as a function, because
React Navigation calls it per route.
## Props
| prop | type | default | description |
| --------- | ----------------- | ------- | ----------------------------- |
| `variant` | `ScaffoldVariant` | `ghost` | How much the header separates |
| `color` | `string` | — | The tint (R7) — a raw value |
| `asChild` | `boolean` | `false` | Renders the caller's element |
Everything else is a style prop (R14) — the ground is a view like any other, so
`backgroundColor` is how an app that paints its page from an image or a gradient gets out
of the way of the theme's flat ground.
```tsx
```
`asChild` merges the ground into the view the app already has, which is one node instead of
two.
## `useScaffold`, for a chrome the slots do not reach
```tsx
const { screenOptions, statusBar } = useScaffold()
```
Resolved values, never props to resolve again (R5). It is what an app spreads by hand onto
a navigator with keys of its own — a drawer's `drawerStyle`, a tab bar — or hands to an
Android navigation-bar module.
Outside a ``, `useAppearance` is the same four values with no wiring: this
component _is_ `useAppearance` with the wiring done.
## What it deliberately does not do
- **No safe-area inset.** The navigator's header already owns the top one, and a scaffold
adding its own would fight `react-native-safe-area-context` for the bottom.
- **No header of its own.** A header belongs to whichever navigator the app chose, and a
status bar belongs to the platform. This hands both what they ask for.
- **No colour-mode toggle.** Which control flips the mode, and where it sits, is the app's
— write it into the navigator's `headerRight` and the merge lets it through.
# SearchField
## Overview
A query, with the mark that says so and the cross that takes it back. Imports from
`@xaui/native/search-field`. Run the `search-field` demo screen to try the real component
in light and dark mode.
It **is** a `TextField`: the same root, the same `size`, `radius`, `color`,
`labelPlacement`, `isInvalid` and `isDisabled`, and `Label`, `Description` and `Error` are
that component's own slots rather than wrappers around them. What differs is the fill —
two variants instead of four, neither carrying the `field` shadow — and the two marks.
The built-in magnifier is drawn with `react-native-svg`, an **optional** peer of this
package. `SearchField.Icon` takes an `as` for a project whose icon set already has one.
## Anatomy
```text
SearchField
Label
FieldGroup
FieldGroup.Prefix → Icon
Field
Clear
Description / Error
```
## Usage
```tsx
import { FieldGroup } from '@xaui/native/field-group'
import { SearchField } from '@xaui/native/search-field'
;
Find products
Search by name, category, or SKU
```
**Typing and searching are two callbacks, on purpose.** `onValueChange` fires on every
keystroke and on the clear, which is what a list filtered in memory reads; `onSearch` fires
on the keyboard's search key, which is what a query costing a request waits for. A single
callback for both would make the caller debounce to tell them apart.
Use `value` with `onValueChange` for a controlled query, or `defaultValue` and let the
field keep its own. `SearchField.Clear` empties the box through the same path a keystroke
takes, so a filtered list comes back on its own. It renders nothing while the box is empty
— and it is its own `FieldGroup.Suffix`, unlike `Icon`, because a decorator is inset by the
field's own padding and an empty one would go on holding room for a cross that is not
there.
`SearchField.Field` takes everything `TextField.Field` takes except `value`,
`defaultValue`, `onChangeText` and `onSubmitEditing`, which are the root's. Its three
defaults — `autoCapitalize="none"`, `autoCorrect={false}` and `returnKeyType="search"` —
are the caller's to override.
## Props
Generated from TypeScript by `node tooling/component-docs/generate.mjs search-field`.
The root inherits TextField's styles, states, ref and `asChild` behavior.
### SearchFieldProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| variant | `SearchFieldVariant \| undefined` | The fill. Two, and `primary` is the default. |
| value | `string \| undefined` | The query, controlled. Leave it unset and the field keeps its own. |
| defaultValue | `string \| undefined` | The query it starts with, uncontrolled. |
| onValueChange | `((query: string) => void) \| undefined` | Every keystroke, and the clear — this is what a field filtering as you type reads. |
| onSearch | `((query: string) => void) \| undefined` | The search key on the keyboard. It is **not** every keystroke: a query that costs a request is submitted, and one that filters a list in memory is `onValueChange`. |
### SearchFieldFieldProps
Inherited React Native and composed component props remain available.
### SearchFieldIconProps
Inherited React Native and composed component props remain available.
| Prop | Type | Description |
| ----- | ------------------------------------------------ | ------------------------------------------------------------------- |
| as | `ComponentType \| undefined` | Replaces the built-in magnifier with an icon component of your own. |
| size | `number \| undefined` | Overrides the field's own glyph size. |
| color | `string \| undefined` | A raw value (R7), never a token. Overrides the placeholder grey. |
### SearchFieldClearProps
Inherited React Native and composed component props remain available.
## Slots
`Label`, `Description` and `Error` **are** the `TextField`'s slots, re-exported rather than
wrapped. `Field` is `TextField.Field` with the query and the search key taken over. `Icon`
is `FieldGroup.Icon` with the built-in magnifier as its default mark, so its size and
colour follow the field's own without being passed. `Clear` is the shared close button inside its own
`FieldGroup.Suffix`: it owns its press state, grows its target with `hitSlop`, and draws its
cross from two rotated bars when given no children. Each node accepts its own `style` and style props.
## Variants
Two, where the `TextField` has four:
| `variant` | Fill | For |
| ----------- | ----------------- | ------------------------------------------------------ |
| `primary` | `fieldBackground` | A search box alone on a screen. The default. |
| `secondary` | `defaultSoft` | A search box on a surface — a sheet, a card, a header. |
Neither carries the `field` shadow the `TextField`'s `primary` has: a search box sits alone
rather than in a column of siblings, and there the elevation reads as a card that has lost
its content. `tertiary` and `ghost` are absent because a field with no fill cannot be told
from the text beside it once the placeholder is gone.
The edge, the focus colour and the `isInvalid` red are the `TextField`'s, which is handed
`tertiary` underneath. `color` is a raw tint (R7) and lands on the fill; the four sizes
(`xs`, `sm`, `md`, `lg`) and `radius` are inherited unchanged.
## Accessibility
The field carries `accessibilityRole="search"` and is associated with its label and its
description, so a screen reader reads both rather than falling back to the placeholder.
Give `Clear` an `accessibilityLabel` in your app's language — a cross says "clear" to
someone who can see it and nothing at all to someone who cannot; the component warns in
development when it is missing. Mark the prefix `isDecorative` so the magnifier hands its
touches to the field underneath. `isDisabled` stops the field and the cross together.
## Migration from legacy
This is a new component. There is no legacy SearchField to deprecate.
# Segment
A filter: one of a few options, chosen in place.
## Import
```tsx
import { Segment } from '@xaui/native/segment'
```
## Usage
```tsx
Dashboard
Analytics
```
## Anatomy
| slot | what it is |
| --------------- | -------------------------------------------------- |
| `Segment` | The track, and the pill that slides across it |
| `Segment.Item` | One option |
| `Segment.Label` | The word on it — written for you from a text child |
## It is not `Tabs`
They wear the same clothes: a pill sliding under the chosen option inside a filled track, on
the theme's own `segment` / `segmentForeground` tokens. They do different jobs.
| | `Tabs` | `Segment` |
| ----------------- | ------------------------------ | ---------------------------- |
| what it is for | places to go | a value something else reads |
| what it holds | panels, through `Tabs.Content` | nothing — the value is yours |
| what it announces | `tablist` / `tab` | `radiogroup` / `radio` |
| the indicator | a slot; `light` has none | drawn by the root, always |
A tab bar **wraps content**: its triggers name panels that live under it. A segment names
nothing — it holds a value, the way a radio group does, and what reads that value is
somewhere else on the screen. Which of the two a control is, is what a screen reader hears,
so it cannot be a flag on one component.
## The pill is not a slot
`Tabs` makes you write ` ` because a tab bar can be `light` and have no
indicator at all. A segment without its pill is not a segment, so the root draws it and
there is nothing to remember.
`color` is how you move it — see below.
## Separators, for a list long enough to need dividing
`hasSeparator` draws a hairline between the options the pill is nowhere near. It is off by
default: the pill already says which option is chosen.
**Both edges of the pill stay clear.** A rule running into a raised surface reads as a crack
in it, so neither the chosen option's own leading rule nor the one belonging to the option
after it is drawn. That is the behaviour iOS has had since the segmented control was
introduced, and it is why one does not look like a table.
The rule belongs to the option on its **trailing** side, which is what lets an option decide
alone: the root has no way to know which of its children is which without reading their
props, and reading a child's props is introspection this library does not do. Every option
already publishes its rectangle for the pill to slide to, and an ordering is all
`hasLeadingSeparator` needs — so before the first layout nothing is drawn, and the rules
arrive with the pill rather than a frame ahead of it.
## Props
| prop | type | default | description |
| --------------- | ------------------------- | ------- | ----------------------------- |
| `value` | `string` | — | Controlled |
| `defaultValue` | `string` | — | Uncontrolled |
| `onValueChange` | `(value: string) => void` | — | Fires on every choice |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Option padding, gap and type |
| `radius` | `RadiusKey` | — | Overrides the track and pill |
| `color` | `string` | — | The tint (R7) — a raw value |
| `hasSeparator` | `boolean` | `false` | Hairlines between the options |
| `isDisabled` | `boolean` | `false` | Dims it, stops every option |
| `asChild` | `boolean` | `false` | Renders the caller's element |
No `xs`: a segment holds words, and at that height the word gets nothing.
## It hugs its options
A segment as wide as the screen with two options in it is a navigation bar pretending to be
a filter. A row that has to fill its parent is a `style` away, as everywhere else in this
library — there is no `fullWidth` prop.
## The tint reaches the word, not just the pill
`color` moves the pill **and** the label on it. `fgSelected` is a role rather than a token
named in a state, which is what makes the tint follow into the chosen option's colour — the
tint pass re-runs `paint`, not the axes. Without that, a tinted segment would slide a
coloured pill under a word that had stopped reading against it.
## The pill slides on the UI thread
`useSlidingIndicator`, shared with the `Tabs`. Nothing is drawn before the first layout —
an indicator at zero width would flash at the start of the row — and the first placement
jumps where every one after it springs, because animating the first reads as the control
arranging itself rather than as one at rest.
## Everything else is a style prop
`padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on
every slot.
# Select
A field that opens a list. It is the `TextField`'s twin: the same `field*` tokens, the
same four levels, the same heights — so the two sit in a form and read as one control.
## Import
```tsx
import { Select } from '@xaui/native/select'
```
## Anatomy
```tsx
…
…
…
…
…
…
```
- **`Select`** — the column, and the state and resolved style behind it.
- **`Select.Label`** — what the field is for, above the trigger.
- **`Select.Trigger`** — the control. The field the user sees, and the node a `ref`
reaches.
- **`Select.Value`** — what is chosen, or the placeholder until something is.
- **`Select.Indicator`** — the chevron, turning on a spring with the list.
- **`Select.Overlay`** — the backdrop. Optional, and what closes the list on an outside
press.
- **`Select.Content`** — the list, positioned against the trigger and rendered in a portal.
- **`Select.GroupLabel`** — a heading over a run of rows, **inside the panel**.
- **`Select.Item`** — one row.
- **`Select.ItemLabel`** / **`Select.ItemDescription`** — its two lines.
- **`Select.ItemIndicator`** — the check on the chosen row.
- **`Select.Description`** / **`Select.Error`** — the help line under the field.
**The root is the column**, a `View` stacking the label, the trigger and the help line
with one `gap` — the `TextField`'s shape and the `Autocomplete`'s, token for token, so a
text field and a select on the same form read as one control. `ref`, `style` and R14's
style props on the root dress that column; the control itself is `Select.Trigger`, which
keeps its own `ref` and its own a11y props.
`Select.Overlay` and `Select.Content` render into the nearest `PortalHost` rather than
where they are written, so they add nothing to the column. Their place in the JSX says
**when** they exist, not where they appear.
**`Select.Label` is the field's label, not the panel's.** The heading over a run of rows
is `Select.GroupLabel` — the two were one name until the field grew a label of its own.
## Usage
### Basic
```tsx
Langue
Français
English
Celle de l'interface.
```
A stringifiable child becomes the row's label (R3), and that same string is what the
trigger shows once the row is chosen — the label is written once.
### Label, hint and error
```tsx
Langue
…
{locale ? (
Celle de l'interface.
) : (
Choisissez une langue.
)}
```
`isInvalid` paints the trigger's border, turns the label and the description `danger`, and
**mounts nothing**: `Select.Error` renders exactly what it is given, so the condition stays
where you can read it. The label and the description carry the ids the trigger points at
with `aria-labelledby` / `aria-describedby`.
### Composed rows
```tsx
Français
La langue par défaut
```
`label` is what the trigger shows. It is needed as soon as the children are not one
string: an avatar and two lines of text have no single label to infer.
The labels are read off the **elements**, before any row mounts. That matters because the
list lives in a portal that only exists while it is open — without it, a select with a
`defaultValue` would show its placeholder until the user had opened it once. A row wrapped
in a component of your own is not reachable that way, and `children` on `Select.Value` is
the answer for that case.
### Controlled
```tsx
const [value, setValue] = useState('fr')
…
```
Open state is controllable the same way, with `isOpen` / `onOpenChange`. Which half is
controlled is decided on the first render and then held — a component that changes hands
mid-life produces a bug nobody can read from the call site.
### Placement
```tsx
```
`width` defaults to `'trigger'`, because a list wider than the control it drops out of
reads as a different surface. `'content-fit'` hugs the longest label instead, bounded by
the screen insets.
`avoidCollisions` is on by default: the list flips to the other side when the one it was
asked for does not fit. It flips only when the other side has strictly **more** room, so
a list two points short stays where it was put rather than jumping.
### No backdrop
```tsx
…
{/* no Select.Overlay */}
…
```
Omit `Select.Overlay` and only a choice closes the list. That is the answer for a select
inside a sheet that already dims its own background.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them — the trigger, the panel, the
overlay, each row and each of its texts.
### Everything else goes through `style`
A gradient panel, a tinted shadow, a transform — `style` is the last word and wins over
everything above it.
## Props
### `Select`
| prop | type | default | description |
| --------------- | --------------------------- | --------- | --------------------------------------- |
| `variant` | `SelectVariant` | `primary` | The field's four emphasis levels |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | The **control's** scale, not the list's |
| `radius` | `RadiusKey` | — | Overrides the field radius |
| `color` | `string` | — | The tint (R7) — a raw value |
| `value` | `string` | — | Controlled selection |
| `defaultValue` | `string` | — | Uncontrolled selection |
| `onValueChange` | `(value: string) => void` | — | Fires on every choice |
| `isOpen` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Uncontrolled open state |
| `onOpenChange` | `(isOpen: boolean) => void` | — | Fires on open and on close |
| `isDisabled` | `boolean` | `false` | Dims the column, stops the press |
| `isInvalid` | `boolean` | `false` | Border, label and hint to `danger` |
| `asChild` | `boolean` | `false` | The caller's element is the column |
Plus everything `View` takes and `ViewStyle` as props — they dress the **column**.
### `Select.Trigger`
Everything `Pressable` takes, plus `ViewStyle` as props. `asChild` renders the caller's
element as the control.
### `Select.Value`
`placeholder`, plus everything `Text` takes and `TextStyle` as props. Single-line by
default: a value long enough to wrap would grow a control whose height is fixed.
### `Select.Indicator`
`as`, `size`, `color`. Defaults to the chevron this component ships.
### `Select.Overlay`
`isDismissable` (default `true`), plus `ViewProps` and `ViewStyle` as props. It paints
nothing until a `backgroundColor` says so.
### `Select.Content`
| prop | type | default |
| ----------------- | -------------------------------------- | ----------- |
| `placement` | `'top' \| 'bottom'` | `bottom` |
| `align` | `'start' \| 'center' \| 'end'` | `center` |
| `width` | `number \| 'trigger' \| 'content-fit'` | `trigger` |
| `minWidth` | `number` | — |
| `offset` | `number` | `8` |
| `alignOffset` | `number` | `0` |
| `avoidCollisions` | `boolean` | `true` |
| `insets` | `SelectInsets` | `12` a side |
### `Select.Item`
`value` (required), `label`, `isDisabled`, `asChild`. `children` may be a function taking
`{ isSelected, isPressed, isDisabled }`, which is the escape hatch for a row that paints
its own selected state instead of showing the check.
### `Select.Label` · `Select.Description` · `Select.Error`
Everything `Text` takes, plus `TextStyle` as props. They are the `TextField`'s three,
token for token. `Select.Label` and `Select.Description` carry the ids the trigger points
at; `Select.Error` carries none, because it is the description's replacement rather than a
second thing to announce.
### `Select.GroupLabel` · `Select.ItemLabel` · `Select.ItemDescription`
Everything `Text` takes, plus `TextStyle` as props. The item label truncates; the item
description wraps, because the thing it exists to carry is the sentence a label was too
short for.
### `Select.ItemIndicator`
`as`, `size`, `color`. Its 20-point box renders on every row and only the glyph comes and
goes, so choosing never shifts a label.
## Motion
Three animations, the reference implementation's values throughout.
**The chevron** turns 0 → −180° on a spring: damping 140, stiffness 1000, mass 4. Heavily
damped against a very high stiffness, so it arrives in about a fifth of a second and does
not overshoot — an oscillating chevron reads as a bug rather than as motion. It is a
worklet, so it keeps turning while JavaScript mounts the rows.
**The panel** grows out of the trigger: 200 ms in from `scale: 0.95` and eight points
offset **towards** the trigger, so a list below enters upwards and one above enters
downwards. Exit mirrors it at 150 ms — closing is an acknowledgement, not an arrival, and
a dismissal as long as the opening feels like the control is arguing.
**The overlay** fades on the same two durations.
**The measuring pass.** The panel mounts invisibly for one frame to learn how tall it
wants to be, then places itself and plays its entrance. Without it `avoidCollisions` has
nothing to compare, and a list that does not fit below would open downwards off the
screen.
## Accessibility
The trigger is a `button` carrying `expanded`, so a screen reader announces whether the
list is open. Rows are `menuitem` and carry `selected`. `Select.GroupLabel` is a
`header`, so the group it opens is announced with it. The trigger points at
`Select.Label` and `Select.Description` with `aria-labelledby` / `aria-describedby`, so a
screen reader announces what the field is for rather than reading the placeholder and
hoping. The overlay announces nothing at all — it is the
absence of the list, and "button" spoken over the whole screen is worse than silence.
## The portal
`Select.Overlay` and `Select.Content` need a `PortalHost`. `XAUIProvider` mounts one, so
there is nothing to do. An app that needs the host elsewhere — under a gesture root, or
inside its own navigation container — sets `hasPortalHost={false}` on the provider and
mounts a `PortalHost` itself.
Without a host anywhere, `Portal` renders nothing: the select opens onto an empty screen
with no error. That silence is why the provider mounts one by default.
## Migration from `@xaui/native-legacy`
The legacy component is `Select`, with `SelectItem`.
| Legacy | v1 |
| ---------------------------- | ---------------------------------------------- |
| ` ` | `` with its own slots |
| `label="…"` on the root | `` |
| `description="…"` | `` |
| `errorMessage="…"` | `` |
| `placeholder="…"` | `placeholder` on `` |
| `variant="colored"` | `variant="primary"` |
| `variant="light"` | `variant="secondary"` |
| `variant="bordered"` | `variant="tertiary"` |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `selectionMode="multiple"` | not in v1 — one value, one control |
| `customAppearance={{ … }}` | `style` on the slot that key named |
**`selectionMode` is gone.** A select that returns several values is a different control
with a different affordance, and calling both by one name is what made the legacy props
list as long as it is. It comes back as its own component or not at all.
# Skeleton
The shape of what has not arrived yet.
## Import
```tsx
import { Skeleton } from '@xaui/native/skeleton'
```
## Anatomy
```tsx
```
**One node and no slots.** A placeholder is a rectangle; there is nothing inside it to
name. A paragraph of them is three of these in a `Column` — composition doing what a
`lines={3}` prop would otherwise hard-code, including the last line being shorter, which is
the only reason the paragraph reads as a paragraph.
## Usage
A block, sized by the caller:
```tsx
```
A paragraph:
```tsx
```
As a gate, which is what `isLoading` is for:
```tsx
{user?.name}
```
A row of one, mirroring the layout it stands in for:
```tsx
```
## Props
| Prop | Type | Default |
| ----------- | ------------------------------ | ------- |
| `radius` | `RadiusKey` | `'md'` |
| `color` | `string` — a raw hex tint (R7) | — |
| `isLoading` | `boolean` | `true` |
| `animation` | `boolean` | `true` |
| `children` | `ReactNode` | — |
| `style` | `StyleProp` | — |
Plus every `ViewStyle` key as a prop (R14) and every `View` prop.
### There is no `size`, and that is the design
Only the caller knows the shape of the thing that is missing, so R14's `width` and `height`
are the whole sizing API — full React Native names and values, `width="60%"` as readily as
`width={140}`. A `size` token here would be a scale of rectangles nobody's content happens
to be.
### No `variant`
It had two — the neutral fill and that fill at half — sold as the two backgrounds a
placeholder is drawn on. Measured against every surface, in both modes, the second is
**less** visible than the first everywhere:
| surface | `default` | the old `secondary` |
| ---------------------------- | --------- | ------------------- |
| the page (light) | **1.216** | 1.100 |
| a `default` `Card` (light) | **1.269** | 1.119 |
| a `secondary` `Card` (light) | **1.075** | 1.036 |
| the page (dark) | **1.336** | 1.123 |
| a `default` `Card` (dark) | **1.189** | 1.090 |
| a `secondary` `Card` (dark) | **1.000** | **1.000** |
So it was never the answer to "this block reads as a hole" — the full fill is the _more_
visible of the two on the surface that claim named. And on a `secondary` `Card` in dark mode
both resolve to that surface's own `#27272a` and vanish, which is the case the pair was
supposed to cover.
A skeleton has to contrast with whatever sits under it, and a fixed token cannot know what
that is. Two frozen values were never going to cover three surfaces times two modes. The
block paints `default`, and `color` is the way past it — honest about being a raw value
rather than a name that promises a system.
### `radius="full"` is the avatar placeholder
It is a prop rather than the caller's own `borderRadius` because a circle is a shape the
vocabulary names, and it has to survive a theme redrawing every corner in the library.
### No `asChild`
R12 asks for one on every root, and this is the second of the two places in the library it
is deliberately absent. `children` is already taken here, and it means the opposite of what
`asChild` would need: the content the block stands in for, rendered once `isLoading` is
`false`. `asChild` hands `Slot` an element to merge the block's styles into. One `children`
with two meanings, disambiguated by a second prop, is the kind of API this library exists
not to ship.
Styling is the escape hatch instead: every `ViewStyle` key is a prop (R14).
## Animation
One pulse: the block breathes between full opacity and a half, a second each way, eased in
and out. `animation={false}` freezes it at full and mounts no worklet — the branch renders
a plain `View`, so a long list frozen for a screenshot costs nothing.
**No shimmer**, where the reference implementation's default is one. A shimmer is a gradient sweeping across the
block; a gradient needs `react-native-svg`, and that is an optional peer a component in the
fifteen-component core cannot require. One animation, so there is nothing for a name to
choose between — `animation` is a boolean rather than the reference implementation's
`'shimmer' | 'pulse' | 'none'`.
## Accessibility
`accessibilityElementsHidden` and `importantForAccessibility="no-hide-descendants"` are
set. A block stands in for content that is not there; announcing its absence is `busy` on
whatever region is loading, not a stop on each rectangle. Both stay overridable (R9).
## Migration
| Legacy | v1 |
| ------------------------------------- | --------------------------------------------------- |
| `` | ` ` |
| ` ` | ` ` |
| ` ` | three `` in a `` |
| ` ` | ` ` |
# SlideButton
A button you drag rather than tap — slide the thumb to the end and it fires once. It is
the confirmation for an action that must not happen by accident.
> **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of
> them; it ships under `1.x` as a P5 component.
## Import
```tsx
import { SlideButton } from '@xaui/native/slide-button'
```
## Anatomy
```tsx
Slide to confirm
```
| Slot | What it is |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `SlideButton` | The pill. Owns the geometry, the gesture's shared offset and the confirmed state. |
| `SlideButton.Fill` | The trail behind the thumb. Optional — compose it for the affordance, omit it for a bare pill. |
| `SlideButton.Label` | The instruction, centred across the whole pill. The thumb slides over it. |
| `SlideButton.Thumb` | The handle the finger drags. Draws the built-in chevron, or renders whatever you put in it. |
| `SlideButton.Icon` | A mark in the thumb, `system/`'s `Icon` reading the handle's glyph size and colour. |
**A bare string is the whole component.** `Slide ` composes the
fill, the label and the thumb for you. Write the slots out only to drop the fill or to put
a mark in the thumb.
## Usage
### The one-shot
```tsx
Slide to submit
```
`onConfirm` fires once, when the thumb reaches the threshold. Uncontrolled, the thumb then
stays at the end — a slide is a one-shot, and springing back would read as the confirm not
having taken.
### Driven, and re-armed
```tsx
const [confirmed, setConfirmed] = useState(false)
setConfirmed(true)}>
{confirmed ? 'Sent' : 'Slide to send'}
setConfirmed(false)}>Undo
```
Pass `isConfirmed` and the thumb follows it: to the end when it is set, home when it is
cleared. Setting it back to `false` re-arms the control.
### The threshold
```tsx
Slide all the way
```
How far along the track the thumb has to reach for the slide to count, from `0` to `1`.
Below it, the thumb springs home on release. The default is `0.9` — far enough to be
deliberate, short enough to forgive the last few points.
### A mark in the thumb
```tsx
Slide to pay
```
Anything inside `SlideButton.Thumb` replaces the built-in chevron. `SlideButton.Icon`
inherits the handle's glyph size and — because the handle is the surface colour whatever the
pill does — the theme's foreground.
### Style as props
```tsx
SLIDE
```
Every node carries R14 — full RN names, full RN values, scoped to the node they are written
on.
## Sizes
| `size` | Height | Handle | Label |
| ------ | ------------------- | ------------------------------- | ----- |
| `sm` | `controlHeights.sm` | (height − 6) tall, 1.5× as wide | `sm` |
| `md` | `controlHeights.md` | (height − 8) tall, 1.5× as wide | `md` |
| `lg` | `controlHeights.lg` | (height − 8) tall, 1.5× as wide | `lg` |
`size` drives the pill's height, the handle and the label's type — **never a width**.
Without an `alignSelf`, the pill fills its column, which is RN's own behaviour and the
`Button`'s. The label stays centred across the whole pill at every size.
The handle is a **horizontal stadium**, wider than it is tall: a slide-to-confirm handle
is pushed sideways, so it reads as a thing you shove rather than a knob you turn.
It is lifted off the pill by `shadows.surface`, so the chevron on it never has to fight the
pill for contrast — and **`isDisabled` puts that lift down** rather than dimming it with
everything else. Opacity fades the pill until its edges are most of the way to the page,
and a shadow at half strength is then the strongest edge left, sitting a point below a
handle whose own outline has gone: it reads as a handle pressed into a dent rather than as
a faded control. Nothing disabled should look lifted anyway.
For the same reason the pill itself never sets `overflow: 'hidden'`. On iOS that clips the
layer's own shadow, and the handle's lower half reaches the pill's edge exactly. The trail
is cut to the pill's shape by a window one layer in, so the cut lands on the trail alone.
`radius` overrides the pill's corner, which is `full` by default.
## Variants and colour
The ten flat variants, the same vocabulary every control in the library takes — a
slide-to-delete is a real `danger` use, so the intents stay.
| `variant` | Pill at rest | Trail | Label at rest | Label swept |
| -------------------------------- | ------------ | -------- | ---------------------- | ------------------ |
| `primary` | `accentSoft` | `accent` | `accentSoftForeground` | `accentForeground` |
| `success` / `warning` / `danger` | `Soft` | `` | `SoftForeground` | `Foreground` |
| `secondary` | `default` | wash | `defaultForeground` | same |
| `tertiary` | — (`border`) | wash | `foreground` | same |
| `ghost` | — | wash | `foreground` | same |
| `*-soft` | `Soft` | wash | `` | same |
`secondary` is the default — the neutral grey pill.
**The four intents rest soft and earn their colour.** A `Button` is its intent the moment
it is on screen, because a tap is the whole interaction. A slide is not: the pill sits
under the thumb saying _not yet_, so `primary`, `success`, `warning` and `danger` open on
their soft slice and name the vivid one as `bgSelected` — the role the recipe engine
already has for "the box once it is on". The trail lays it down as the handle sweeps, so
the colour arrives with the commitment rather than before it.
**The label is drawn twice**, and that is what keeps it readable. No single text colour
survives a pill going from its soft slice to the vivid one: painted for the soft pill it
reads about 1.3:1 against the trail, painted for the trail it is invisible at rest. So the
second copy takes `fgSelected` and is clipped to exactly the trail's width — a word the
trail is halfway through is dark on the half that is still soft and light on the half that
is not. Measured against the default theme, every variant clears AA in both modes: 4.9–6.5
at rest and 4.6–5.0 swept in light, 7.5–10.4 and 6.4–10.2 in dark. Where a variant names no
`fgSelected` both copies resolve to the same colour and the second one changes nothing.
`color` is a raw value (R7). It lands where the variant's tokens do: the pill for the
filled ones, the label for `ghost`, the label and border for `tertiary` — and, because
`resolveTint` maps every declared role, into the trail on the four that name one. **It
never reaches the handle** — the handle stays the surface colour so the chevron on it is
readable whatever the pill is doing.
**The variants that name no vivid slice sweep a wash of their own foreground** instead, at
low opacity — the same colour the label uses, so it reads against every pill without a
token of its own. Either way the trail's width is the swept **fraction** of the travel laid
over the whole pill — nothing at rest, and full at the end. It is not the handle's raw
offset: the handle stops a handle's width short of the trailing cap, because that is where
it physically is, and a trail that stopped with it would leave a confirmed slide showing
unswept pill. How far the slide has come, not the value.
## How it is put together
It is the `Slider`'s gesture without the value. `SlideButton.Thumb` runs a `Gesture.Pan`
on `react-native-gesture-handler` — the same **optional** peer the `Slider` reaches for,
imported by these two components and nowhere else. The pill measures its own width on
layout, and the travel is inset by the thumb and its margin at each end, exactly as the
`Slider`'s rail insets the knob.
Everything the finger does stays on the UI thread: the thumb's offset is a shared value the
pan writes and the fill and the handle both read. The single hop to JS is `scheduleOnRN` on
release, once, when the slide has passed the threshold — the confirm is React state and a
callback, and neither belongs on a worklet.
There is **no `min`, `max` or `step`**, because the only positions that mean anything are
"not yet" and "done".
## Accessibility
- `accessibilityRole="button"`, overridable.
- `accessibilityState` carries `disabled`; a caller's own keys are merged over it.
- **A drag is not available to a screen reader.** The control exposes an `activate` action
— the way in for VoiceOver and TalkBack — as an `accessibilityAction` rather than an
`onPress`, so it stays not-tappable-to-confirm for everyone else, which is the point of
it. Give it an `accessibilityLabel` that says what the slide does.
- Under RTL the thumb travels toward the leading edge and the built-in chevron flips to
point that way on its own — its lit borders are `top` and `end`.
## Props
Everything a `View` accepts, every `ViewStyle` key it does not already claim (R14), plus:
| Prop | Type | Default | Notes |
| ------------------ | ---------------------- | ------------- | ------------------------------------------------------- |
| `variant` | `SlideButtonVariant` | `'secondary'` | The ten flat values |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Height, thumb and label — never a width |
| `radius` | `RadiusKey` | `'full'` | The pill's corner |
| `color` | `string` | — | A raw tint — the pill, or the label; never the thumb |
| `threshold` | `number` | `0.9` | How far to slide, from 0 to 1 |
| `isConfirmed` | `boolean` | — | Controlled — pins the thumb at the end, or springs home |
| `defaultConfirmed` | `boolean` | `false` | The starting state when uncontrolled |
| `onConfirm` | `() => void` | — | Fires once, when the thumb reaches `threshold` |
| `isDisabled` | `boolean` | `false` | Dims it, drops the handle's shadow, stops the drag |
# Slider
A value chosen along a line.
## Import
```tsx
import { Slider } from '@xaui/native/slider'
```
`react-native-gesture-handler` is an **optional** peer of this package, and this is the
component that needs it. It is imported here and nowhere else, so only an app that reaches
for `@xaui/native/slider` pays for it.
## Anatomy
```tsx
```
- **`Slider`** — the value, the range, and the styles the slots read.
- **`Slider.Output`** — the value in words. Optional.
- **`Slider.Track`** — the line, and the only node that knows how long it is.
- **`Slider.Fill`** — the part behind the thumb.
- **`Slider.Thumb`** — what the finger holds.
## Usage
### Basic
```tsx
```
### Two callbacks, and the difference matters
`onValueChange` fires on every step the thumb crosses, **including mid-drag**. It is what a
live preview reads.
`onValueCommit` fires **once**, when the finger lifts. It is where a network call belongs,
because the first one can fire fifty times in a second.
### Steps
```tsx
```
Eleven stops at 5, 15, 25 … The snap counts steps **from the minimum** rather than rounding
the value, so that range stops where it says it does and not at 10, 20, 30. `step={0}` is
continuous.
### Formatting the output
```tsx
{value => `${value} %`}
```
`children` may be a function of the value. A `format` prop would have been the same thing
with less room in it.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `Slider`
| prop | type | default | description |
| --------------- | ------------------------- | ------- | ---------------------------------- |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Track thickness, thumb width, type |
| `radius` | `RadiusKey` | — | Overrides every corner |
| `color` | `string` | — | The tint (R7) — a raw value |
| `value` | `number` | — | Controlled |
| `defaultValue` | `number` | `0` | Uncontrolled |
| `onValueChange` | `(value: number) => void` | — | Every step, mid-drag included |
| `onValueCommit` | `(value: number) => void` | — | Once, when the finger lifts |
| `min` | `number` | `0` | |
| `max` | `number` | `100` | |
| `step` | `number` | `1` | `0` is continuous |
| `isDisabled` | `boolean` | `false` | |
No `variant`, and no `xs`. A slider reports a quantity rather than an intent — a `danger`
volume control would be colouring a number — and a track four points tall is a line, not a
control.
### `Slider.Thumb`
`accessibilityValueText` turns the raw number into what a screen reader reads.
## How it is put together
**The travel is inset by half a thumb at each end.** Otherwise the thumb hangs over the
track's ends at the minimum and the maximum, and the fill runs out from under it. The fill
runs to the thumb's **centre** rather than to the raw proportion, for the same reason.
**A press anywhere on the track moves the thumb there.** It is the half of a slider people
forget: dragging a narrow thumb is a fine gesture on a mouse and a poor one on a finger.
**The thumb grows 15% under the press rather than moving.** The finger is already covering
it, so the scale is what you see in the gap around it — and it is the only confirmation a
slider can give that the drag has started. Stiff and well damped: a confirmation should
arrive, not wobble.
**The value crosses to the JS thread and the scale does not.** The pan computes the new
position on the UI thread and hands the value back over `scheduleOnRN`, which is the one hop
that has to happen — the value is React state. The scale stays where it is.
## Not here yet
**A range slider**, with two thumbs. The context already carries what a second thumb would
need, and `useSlider` is exported for anyone who wants to write one — but two thumbs is two
values, a different type for `value`, and rules about which one gives way. Worth its own
change.
**A vertical slider.** The same arithmetic on the other axis, but also a different gesture
and a different layout.
## Accessibility
The thumb is `adjustable`, carrying `min`, `max` and `now`, and it answers the platform's
increment and decrement actions by one `step` — without that the platform guesses one
percent of the range, which on a slider of five stops moves nothing.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| -------------------------- | ---------------------------------- |
| `value` / `onValueChange` | unchanged |
| `onSlidingComplete` | `onValueCommit` |
| `minimumValue` | `min` |
| `maximumValue` | `max` |
| `themeColor="primary"` | `color={theme.colors.accent}` |
| `customAppearance={{ … }}` | `style` on the slot that key named |
# Snackbar
A temporary, controlled message inspired by the legacy Snackbar rather than by the v1
Toast queue. Several messages share a vertical anchor through `Snackbar.Stack`, so they
never overlap.
## Import
```tsx
import { Snackbar } from '@xaui/native/snackbar'
```
## Usage
```tsx
Enregistré.
Annuler
Fermer
```
Several independently controlled messages go in one stack:
```tsx
Enregistré.
Connexion interrompue.
```
## Anatomy
- `Snackbar` owns visibility and its timer, plus placement and portal when used alone.
- `Snackbar.Stack` owns one placement and portal for several snackbars, then lays them out
vertically without overlap.
- `Snackbar.Message` is the announcement.
- `Snackbar.Actions` groups trailing controls.
- `Snackbar.Action` runs an action and dismisses by default.
- `Snackbar.ActionLabel` is the action text; raw action text is wrapped in it.
- `Snackbar.Close` only dismisses.
`duration={0}` keeps it visible. `position`, `insetHorizontal`, `insetVertical`, `maxWidth`
and `isPortalled` correspond to the legacy layout controls. The legacy `message`,
`actionLabel`, `showCloseAffordance` and `customAppearance` configuration props become
explicit slots and each slot accepts its own style props.
Inside `Snackbar.Stack`, each Snackbar keeps its own visibility, timer, content and actions;
the stack takes over placement, width and portalling. Its `position`, `spacing`,
`insetHorizontal`, `insetVertical`, `maxWidth` and `isPortalled` props control the shared
anchor. The last child stays closest to the selected edge: a bottom stack grows upward and
a top stack grows downward.
The root has the `alert` role and a polite live region. Entrance and exit run through
Reanimated.
# Spinner
The wait, drawn. A circle whose arc turns until whatever is loading has loaded.
## Import
```tsx
import { Spinner } from '@xaui/native/spinner'
```
## Anatomy
```tsx
```
**Two rings and no slots.** The root is the track — the full circle in the variant's ink,
at a fraction of its opacity — and its one child is the arc that turns over it: the same
circle with a quarter missing. The two are one figure rather than two parts, so there is
nothing between them for a slot to name.
## Usage
```tsx
```
Beside a label, in a row:
```tsx
Synchronisation…
```
Frozen, for a screenshot or a test:
```tsx
```
## Props
| Prop | Type | Default |
| ----------- | ------------------------------------------------------------------------------------------- | ----------- |
| `variant` | `'primary' \| 'secondary' \| 'default' \| 'tertiary' \| 'success' \| 'warning' \| 'danger'` | `'primary'` |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` |
| `color` | `string` — a raw hex tint (R7) | — |
| `animation` | `boolean` | `true` |
| `style` | `StyleProp` | — |
Plus every `ViewStyle` key as a prop (R14) and every `View` prop.
### `variant` names an ink
A spinner has no fill, so a variant here names **the colour of the thing itself** rather
than a colour that reads on a surface. That is why `primary` is `accent` and not
`accentForeground` — the value it resolves to on a `Chip`, where there _is_ a fill.
| `variant` | ink |
| ----------- | ---------------------- |
| `primary` | `accent` |
| `secondary` | `accentSoftForeground` |
| `default` | `foreground` |
| `tertiary` | `muted` |
| `success` | `success` |
| `warning` | `warning` |
| `danger` | `danger` |
**No `ghost`**: a spinner with no ink is not a spinner. **No `-soft` slices**: a soft slice
is a fill softened, and there is no fill here.
### `size` is the diameter
Sixteen, twenty, twenty-four and thirty-two points — the reference implementation's three steps plus the one our
ladder adds between the first two. The stroke thickens once, at `lg`, because a 2pt ring on
a 32pt circle reads as a hairline and a 3pt ring on a 16pt one reads as a doughnut.
A spinner is a circle, so `size` is the only measurement it has. R14's `width` is available
and deliberately not what `size` means — a spinner that filled its parent would be a
progress bar.
## Accessibility
`accessibilityRole="progressbar"` and `accessibilityState={{ busy }}` are set, and both
stay overridable (R9). There is no default `accessibilityLabel`: only the caller knows what
is loading, and "loading" alone is what the role already announces.
## Notes
### No `asChild`
The one root in the library without one (R12). `Slot` merges a root's props into a single
element, and that element keeps its own children — so a caller's element could become the
track, but the arc is a second node it has no way to receive. Handing over the track alone
would render a ring that never turns, silently.
Styling is the escape hatch instead: every `ViewStyle` key is a prop (R14), and `style` is
still the last word.
### Why borders and not an SVG stroke
The reference implementation draws one arc fading from opaque to 55%, which needs a `linearGradient` and
therefore `react-native-svg`. That package is an **optional peer** here, and a component in
the fifteen-component core cannot require one. Two circles of a single ink at two opacities
read as the same figure, cost two views, and pull in nothing.
### `Button.Spinner` is not this component with props
The button's spinner takes its diameter and its colour from the button's own recipe, which
has already resolved both. Handing them to ` ` would be R6 in
reverse — a vocabulary prop carrying a computed number. What the two share is the turn, and
that lives in `useRotation`.
## Migration
| Legacy | v1 |
| -------------------------------- | ------------------------------ |
| ` ` | ` ` |
| ` ` | ` ` |
| ` ` | ` ` |
# Stepper
Where you are in a sequence of steps.
## Import
```tsx
import { Stepper } from '@xaui/native/stepper'
```
## Usage
```tsx
Compte
Créez votre compte
Profil
```
## Anatomy
| slot | what it is |
| --------------------- | ------------------------------------------------------ |
| `Stepper` | The container, and the thing that numbers its children |
| `Stepper.Item` | One step |
| `Stepper.Indicator` | Its circle, and the rail the circle sits on |
| `Stepper.Content` | What the step is about |
| `Stepper.Title` | The step's name |
| `Stepper.Description` | The line under it — vertical steppers only |
## The value is the caller's, always
There is **no `defaultValue` and no `onValueChange`**. Nothing inside a stepper can move the
value: a step is not a control, it is a report. The number comes from the form, the wizard
or the route that actually knows which step it is on, and an uncontrolled stepper would be a
piece of state that could never change.
`value` counts from **one**, so `value={2}` is "step 2 of 4" — the number you would say out
loud, not an array index.
Out of range is not an error. `value={0}` leaves every step upcoming, which is the honest
reading of a flow that has not started, and a value past the last step completes them all,
which is what a finished one looks like.
## The root numbers its children
An item declares no index and no key of its own. JSX order is step order (R4), so inserting
a step in the middle renumbers the rest by being there:
```tsx
… {/* completed */}
… {/* completed */}
… {/* current */}
```
It is the reasoning that puts the `Accordion`'s separators on its root: what an item cannot
know about its neighbours belongs to the thing that has them all. A step reads its own
standing through `useStep`, or through a function child:
```tsx
{({ status }) => (status === 'current' ? : )}
```
## Three statuses, and they are an order
| status | indicator | title | line leaving it |
| ----------- | ------------------ | ---------- | --------------- |
| `completed` | accent disc, check | foreground | travelled |
| `current` | accent ring | foreground | track |
| `upcoming` | neutral ring | muted | track |
Every step before the current one is `completed` and every step after it is `upcoming` — a
stepper with two current steps is not a stepper.
A **completed step keeps its full contrast**: it is a thing you did, not a thing greyed out.
What recedes is the road ahead, so the eye finds where it is rather than counting from the
top.
The line under the current step is still track, not progress: the stepper has not left that
step yet.
## Two orientations, and they differ by more than the axis
`vertical` puts the indicator beside the text and aligned to the **top** of it, with the
line running down through whatever height that text takes. It is the layout that can carry
a description at all.
`horizontal` centres each indicator over a label and gives every step the same width, so the
circles land at even intervals whatever the labels say. There is no room under a label for a
second line, which is why `Stepper.Description` is a vertical affair.
### Why the connectors belong to the indicator
A vertical line has to run from under one circle to the next **through the text beside it**,
and only something inside that row can measure that height. That is the opposite of the
`Accordion`, whose separator spans the full width and can only be drawn by the root.
A horizontal step carries **two** halves, one on each side of its circle, so the circle stays
centred over its label whatever the neighbours do — two halves meeting at the item boundary
draw one continuous rail. The two ends of that rail are drawn transparent rather than
dropped: removing them would slide the first and last circles off their labels.
`hasConnector={false}` leaves the circles on their own.
## A step is not pressable
There is no `onPress` and no `isLocked`. A stepper where a completed step takes you back is
one composition away:
```tsx
setStep(1)}>…
```
and one where tapping ahead skips a form's validation is not something this component should
make easy.
## The indicator draws its own mark
With no children it shows its number, and a check once the step is behind you — drawn out of
two borders (`utils/check-glyph`, the same tick the `Checkbox` uses) so a stepper works in a
project that has installed no icon set. Children replace both:
```tsx
```
## Props
| prop | type | default | description |
| -------------- | ------------------------------ | ---------- | ---------------------------- |
| `value` | `number` | `1` | Which step, counted from one |
| `orientation` | `'vertical' \| 'horizontal'` | `vertical` | Which way the steps run |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Indicator and type |
| `color` | `string` | — | The tint (R7) — a raw value |
| `hasConnector` | `boolean` | `true` | Whether the rail is drawn |
| `asChild` | `boolean` | `false` | Renders the caller's element |
`size` moves the indicator and the type — **never a width**. A vertical stepper is as wide as
its parent lets it be and a horizontal one splits that width evenly, which is RN's own
behaviour and the reason there is no `fullWidth` prop here either.
## A tint paints the progress, not the track
`color` moves the travelled line, the ring around the step you are on and the disc behind the
ones you are past. The road ahead stays grey — the untravelled track is written from the theme
rather than named as a role, which is what makes that guaranteed rather than incidental. A
stepper whose whole rail took the tint would have stopped showing progress.
## Accessibility
The **root** announces the progress — `accessibilityRole="progressbar"` with an
`accessibilityValue` of `{ min: 1, max: , now: value }`, so a screen reader says "step
2 of 4" once rather than a status on each of the four. Both stay overridable (R9).
The titles and descriptions are read as the text they are. An indicator's number is
decoration on top of that announcement — give it an `accessibilityLabel` if you replace it
with something that carries meaning of its own.
## Everything else is a style prop
`padding`, `gap`, `backgroundColor` — full RN names, full RN values (R14), on the root and on
every slot. There is nothing here a prop had to be invented for.
# Surface
A ground for other things to sit on.
## Import
```tsx
import { Surface } from '@xaui/native/surface'
```
## Usage
```tsx
Sur le fond de la page
Et un cran dedans
```
**One node and no slots**, which is the point: a surface is a fill, a corner and some
padding, and every other component in this library that needed those three has been writing
them out again.
## It is not a `Card`
A card has **decided things for you**. It is always lifted, it has a header, a body and a
footer, and its levels carry an emphasis.
A surface has decided nothing: the levels are a ladder, the shadow is asked for, and what
goes on it is entirely yours. Reach for a card when the thing is a card; reach for a surface
when you need a ground.
## A ladder, not three emphases
| variant | fill | edge | typical place |
| ----------- | ------------------ | -------- | -------------------- |
| `primary` | `surface` | — | on the page |
| `secondary` | `surfaceSecondary` | — | inside a `primary` |
| `tertiary` | `background` | `border` | inside a `secondary` |
A surface reports nothing — it is the thing other reporting sits on — so the levels say
**where** something belongs rather than how loud it is. Three is as deep as that reading
survives; a fourth would be a shade nobody could place.
### `tertiary` is an edge, not a third grey
It takes the page's own `background` and draws itself with a `border`. Below a `secondary`
there is no grey left that still reads as a level, so what marks the level is the line.
Both are tokens the theme states per mode, so the edge lands **darker than the ground in
light and lighter than it in dark** — `#fafafa` inside `#e4e4e7`, `#09090b` inside
`#27272a` — with no branch written in the recipe.
Two consequences to know. A `tertiary` sitting _directly_ on the page shares its fill, so
only the border draws it — the variant working rather than a bug, since it is meant for
inside a `secondary`, where the fill also reads as a step back down.
And a **tinted** `tertiary` loses its edge: `color` lands where the variant put its tokens,
and here that is the fill _and_ the border, so both come back the same colour. A tinted
surface is a filled surface at every level — reach for `borderColor` as a style prop if you
want the edge to survive the tint.
## Props
| prop | type | default | description |
| ------------ | ------------------------------ | --------- | ---------------------------- |
| `variant` | `SurfaceVariant` | `primary` | Which ground |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `md` | Padding, gap and corner |
| `radius` | `RadiusKey` | — | Overrides the corner |
| `color` | `string` | — | The tint (R7) — a raw value |
| `isElevated` | `boolean` | see below | Whether it is lifted |
| `asChild` | `boolean` | `false` | Renders the caller's element |
`size` moves the padding, the gap and the corner — **never a height**. A surface is a
ground: how tall it is, is how tall what is on it is.
## Elevation is asked for
`isElevated` defaults to **true for `primary` only**. A shadow under a ground that barely
differs from the page reads as dirt rather than as height, so the quieter two are flat
until you say otherwise.
That is the second thing separating this from a `Card`, which is always lifted. Whether a
ground is above the one under it is the layout's business: the same `secondary` is flat
inside a card and lifted floating over a list.
## Everything else is a style prop
`padding`, `borderRadius`, `borderWidth`, `borderColor` — full RN names, full RN values
(R14). There is nothing here a prop had to be invented for, which is why this component's
props list is six lines long.
## Where it should be reused
`Card`, `Popover`, `Accordion` and `Dialog` each write out a fill, a corner and a shadow of
their own. None of them reads this yet — that is a refactor, not a component, and it wants
its own change so a regression in one of the four is not hidden inside a new file.
# Switch
A setting that is on or off, and takes effect the moment it is flipped.
## Import
```tsx
import { Switch } from '@xaui/native/switch'
```
## Anatomy
```tsx
```
- **`Switch`** — the root, and it is the **row, not the track**. It is the pressable: it
owns the state, resolves the recipe once (R5) and publishes it to its slots.
- **`Switch.Track`** — the bar the knob slides on, and the surface that says on or off.
- **`Switch.Thumb`** — the knob. The only thing on this component that moves.
- **`Switch.Label`** — what the setting is.
**The root is the row** so that tapping the label flips the switch — the `Checkbox`'s
shape, for the `Checkbox`'s reason.
**R3 — and the track comes with the auto-wrap:**
```tsx
Mode sombre
// is exactly
Mode sombre
```
With no children at all it is the track alone — the form a settings list wants, where the
label is the row's own text.
## A switch is not a checkbox
They look like the same control with two skins and they are not:
| | `Checkbox` | `Switch` |
| ------------- | --------------------------------- | --------------------- |
| When it acts | Later, when the form is submitted | **Immediately** |
| What it says | An intention | A state of the system |
| `isInvalid` | Yes — the value can be wrong | **No** |
| Indeterminate | Yes — a "select all" needs it | No |
There is no `isInvalid` here because a setting that has already taken effect has no later
moment at which it can be wrong. A setting that cannot be turned on is `isDisabled`, with
the reason written beside it.
## Usage
### Controlled, or not
```tsx
Mode sombre
Synchroniser en Wi-Fi seulement
```
`onPress` still fires, composed with the flip.
### The two shapes
```tsx
Le pouce à l'intérieur
Le pouce par-dessus
```
`variant` is a **geometry** axis on this component, which is unusual and deliberate — both
shapes are the accent when they are on, and what changes is where the knob sits:
- **`primary`** (the default) rides the thumb **inside** the track, clear of its edges by
the track's own inset. The shape everything else in this library shares: a filled surface
with something on it.
- **`secondary`** makes the track a thin bar and stands the thumb **over** it, overhanging
above and below. Less ink, and the knob reads as the thing you drag.
They are the legacy component's `inside` and `overlap`, under the library's own two names —
the same two shapes, the same measurements, and one vocabulary across the whole v1 API
instead of a third pair of words for this component alone.
### A glyph on the knob
```tsx
```
The knob's children travel with it. Anything else you put in the track stays where you put
it — a glyph at each end, for instance, which is what the reference implementation's `StartContent` and
`EndContent` are; here they are two `View`s you position, because the track is a node you
were given rather than one that was hidden from you.
### Disabled
```tsx
Géré par votre organisation
```
On the row, not the track: what is disabled is the control, and the label is part of it.
### As another element
```tsx
…
```
R12 — the caller's element **is** the row, so it takes the children it was written with and
the auto-wrap does not apply.
## Sizes
`primary` — the knob inside the track:
| `size` | Track | Knob | Inset | Travel | Label |
| ------ | ------- | ---- | ----- | ------ | ----- |
| `sm` | 44 × 26 | 20 | 3 | 18 | 14/20 |
| `md` | 48 × 28 | 22 | 3 | 20 | 16/24 |
| `lg` | 56 × 32 | 26 | 3 | 24 | 18/28 |
`secondary` — the knob over the bar:
| `size` | Track | Knob | Inset | Travel | Label |
| ------ | ------- | ---- | ----- | ------ | ----- |
| `sm` | 44 × 18 | 24 | 0 | 20 | 14/20 |
| `md` | 48 × 18 | 26 | 0 | 22 | 16/24 |
| `lg` | 56 × 20 | 30 | 0 | 26 | 18/28 |
`md` is the legacy switch measured — a 48 × 28 track with a 22 knob — and the reference implementation's is the
same 48 wide.
**The width is part of the control**, unlike everywhere else in the library, where `size`
never touches it: a switch is a fixed shape, and a track that stretched with its parent
would be a progress bar.
The travel is not a number anyone writes: it is `width − knob − 2 × inset`, arithmetic the
root does on the flattened style, because a slide happens in a worklet and a worklet needs
a number rather than a style to flatten every frame.
`radius` is the **track's** corner, `full` by default. The knob stays round — a squared
track with a round knob is a real design; the reverse is not.
## Colour
```tsx
Teinté
```
A raw tint (R7), and here it is **the colour the switch turns on to**, with a knob derived
to read against it. The track at rest keeps its neutral: a switch that is off is off in
every brand.
It reaches there through the `bgSelected` / `fgSelected` roles, which the tint pass
repaints — the same mechanism as the `Checkbox`'s, written up once in
[`checkbox.md`](../checkbox/checkbox.md#colour).
The knob at rest is the theme's `white`, one of the two places in the library where a
primitive is named on purpose: a switch's knob is white in both modes, the way the
platform's own is, because it has to read against the neutral track **and** against the
accent one.
## Animation
The track's colour is **crossed, not swapped** — `interpolateColor` between the two ends —
and the knob slides on the same 175ms, so a flip reads as one movement rather than as a
repaint under a jump. The number lives in `switch.style.ts` and neither slot owns it, which
is what keeps them arriving together.
`animation={false}` on `Switch.Track` or `Switch.Thumb` puts that one at its end state with
no worklet mounted at all. `animation` on the **root** is the library's usual press knob —
`false`, `'disabled'`, `'disable-all'`, or the object — and belongs to `PressableFeedback`.
**The knob moves with `translateX`, not with an edge.** R13 bans a directional inset, and a
transform is not one — but a transform does not mirror under RTL either, so the sign is
flipped by hand against `I18nManager.isRTL`. It is the one place in the library that reads
it, and it reads it for a movement rather than for a layout.
## Alignment with the reference implementation
Measured against their `switch.tsx` and `switch.css`, and against the legacy component this
one replaces.
**Identical to the reference implementation:** the 48pt track at `md`, the knob's `field` shadow, the accent
track when on with a contrasting knob, the 175ms crossfade, and a default knob so nothing
has to be written for the common case.
**Four deltas:**
| Theirs | Ours | Why |
| ------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| The root is the track; no label | The root is the row, `Label` is a slot | The `Checkbox`'s reason, and the legacy component had a `label` too. |
| A 28 × 20 pill knob | A round knob, 22 at `md` | The legacy switch's, and the platform's. A pill knob in a round track is a shape neither of our two variants is. |
| No shape variants | `primary` / `secondary` | The legacy component shipped both, and dropping one at the migration would be a redesign rather than a port. |
| `StartContent` / `EndContent` | Children of the track | The track is a slot here, so a glyph at either end is a `View` you place — one API instead of two named positions. |
## Props
### `Switch`
Everything `PressableFeedback` accepts, every `ViewStyle` key it does not already claim
(R14), plus:
| Prop | Type | Default | Notes |
| ------------------ | ------------------------------- | ----------- | --------------------------------------- |
| `variant` | `'primary' \| 'secondary'` | `'primary'` | The shape: knob inside, or over the bar |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Track, knob, gap, label type |
| `radius` | `RadiusKey` | `'full'` | The track's corner |
| `color` | `string` | — | A hex tint — the colour it turns on to |
| `isSelected` | `boolean` | — | Controlled |
| `defaultSelected` | `boolean` | `false` | Uncontrolled starting value |
| `onSelectedChange` | `(isSelected: boolean) => void` | — | Fires on every flip, either mode |
| `isDisabled` | `boolean` | `false` | Dims the row and stops the press |
| `animation` | `AnimationProp` | — | The press treatment, as everywhere else |
| `asChild` | `boolean` | `false` | Merge into the single child |
**No `isInvalid`.** See above.
### `Switch.Track`, `Switch.Thumb`
Everything `View` accepts, plus the `ViewStyle` keys as props (R14), plus:
| Prop | Type | Default | Notes |
| ----------- | ----------- | ------- | --------------------------------------------------- |
| `children` | `ReactNode` | — | The knob, for the track; a glyph, for the knob |
| `animation` | `boolean` | `true` | `false` shows the end state with no worklet mounted |
### `Switch.Label`
Everything `Text` accepts, plus the `TextStyle` keys as props (R14).
## Extending it
`useSwitch()` is exported (R10) and carries the resolved styles, the two colour pairs, the
travel and the state — enough to write a glyph that changes at each end of the track, or a
second label:
```tsx
import { useSwitch } from '@xaui/native/switch'
function SwitchState() {
const { isSelected, labelStyle } = useSwitch()
return {isSelected ? 'Activé' : 'Désactivé'}
}
```
Outside a `` it throws by name.
## Accessibility
- `accessibilityRole="switch"` on the root, overridable, with `accessibilityState.checked`
following the value and `.disabled` following `isDisabled`. A caller's own
`accessibilityState` is **merged**, not spread over.
- **The label is inside the control**, so the switch announces itself with its text. A
switch with no label needs an `accessibilityLabel`.
- The whole row is the touch target.
- **The label does not change with the state.** "Mode sombre" is the setting whether it is
on or off; a label reading "Activé" would say what the track and the role already say,
twice.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| ------------------------------ | ------------------------------------------------------------- |
| `label="…"` | `… ` — the text child _is_ the label |
| `labelAlignment="left"` | write `` before `` |
| `labelAlignment="justify-*"` | `justifyContent="space-between"` plus a width, in style props |
| `variant="inside"` | `variant="primary"` — the same shape, the library's name |
| `variant="overlap"` | `variant="secondary"` — likewise |
| `isSelected` / `onValueChange` | `isSelected` / `onSelectedChange`, plus `defaultSelected` |
| `themeColor="primary"` | `color={theme.colors.accent}` — a raw value (R7) |
| `size="sm" \| "md" \| "lg"` | `size` — the same three |
| `radius` | `radius` — a `RadiusKey` now, still `full` by default |
| `fullWidth` | `width="100%"` in style props (R14) |
| `isDisabled` | unchanged, on the root |
| `labelStyle` | `style` on `` |
| `style` | `style` on the root |
# Table
Rows and columns, with a shell round them.
## Import
```tsx
import { Table } from '@xaui/native/table'
```
## Usage
```tsx
Nom
Rôle
{people.map(person => (
{person.name}
{person.role}
))}
…
```
## Three nodes, and each earns its place
- **`Table`** is the shell: the fill, the border, the corner, the shadow. It clips, and it
does not move.
- **`Table.ScrollContainer`** is the horizontal scroller.
- **`Table.Content`** is the column inside it, and the node that is allowed to be **wider
than the shell**.
Folding them together is what makes a wide table either clip its own rows or drag its border
across the screen. The header is inside the scroller with the body, because a header that
stayed put while its cells moved sideways would be a header naming the wrong values.
`Table.Footer` is **outside** the scroller: a pager that slid off with a wide table would be
a pager nobody could reach.
## Widths are declared by the column and read by position
`Table.Column` registers its `width` at its index; `Table.Cell` reads the width at its own.
Neither names the other, and a table stays aligned because both run the same two lines
against the same number:
```
width === undefined ? { flex: 1 } : { width }
```
A column with no width takes an equal share of what is left. `Table.Content`'s `minWidth` is
what stops flexible columns squeezing below a legible width on a narrow phone.
The space between columns is the row's **`gap`**, not a padding on the cell — a padding would
double at the table's two edges, and none at all lets a name and a role run together the
moment a flexible column shrinks to its content.
## The table never reorders anything
Sorting reports the press and the caller sorts their own collection:
```tsx
const sorted = useMemo(() => {
if (sort === undefined) return people
const way = sort.direction === 'ascending' ? 1 : -1
return [...people].sort((a, b) => String(a[sort.column]).localeCompare(…) * way)
}, [people, sort])
```
A table that sorted for you would have to understand every cell's value, and the only thing
that does is the code that built the row.
**Three presses, not two.** A new column starts ascending, the same column turns round, and a
descending column pressed again **clears the sort** — every table that cannot get back to its
own order makes a reader reload the screen to do it.
## Selection
`selectionMode` is `none`, `single` or `multiple`; rows are named by their `id` and reported
as an array of keys.
- **`single` replaces, and pressing the chosen row again clears it.** A single-selection list
with no way back to none is a list a reader can only get wrong once.
- **The header's box has three states** — nothing, some, all. One that only knew the first two
would tell a reader who has chosen four of twenty rows that they have chosen none.
- **It counts only rows that can be chosen**, so a table with a disabled row still has a box
that can be filled.
- **It keeps keys chosen outside this table.** One page of a filtered list must not clear a
choice made on another.
`Table.SelectionCell` reads its _row_ rather than its position, so it works wherever in the
row it is written — a table whose boxes are on the trailing edge is the same JSX with the slot
written last. Pressing the row does the same thing, deliberately: a box the size of a
fingertip inside a row the size of a hand should not be the only way to choose one.
## There is no `virtualized` prop
A table of ten thousand rows is a `FlatList`, and `Table.Body` takes `asChild` so it **is**
one:
```tsx
person.id}
renderItem={({ item }) => … }
/>
```
A prop would have meant this component owning a `data` and a `renderItem` — the shape the
whole v1 API was written to get away from — and owning them badly, since a virtualized list
has a dozen props a table would then forward one by one.
## Variants
`primary` is a raised card, for a table among other things on a page. `secondary` is flat —
the page's own ground with a filled header band — for a table that _is_ the screen.
No `success` or `danger`: a table reports what is in it, and the intent belongs to a `Chip` in
a cell rather than to the shell round all of them.
### The tint does not touch the shell
`color` lands on a chosen row and on the sort mark. It is kept off the root **explicitly**,
because `bg` names `surface` — a bare token — and `resolveTint` would map it to the tint like
any other, turning a blue app's table entirely blue. A tint on a container means the thing it
_marks_, not the ground everything sits on.
## Size
`size` moves the row's height, the cell's inset, the type and the shell's corner. The height
is **fixed**: a value too long is truncated rather than deforming the table.
The corner sits a level below the `Card`'s at the same size, and `radius` overrides it. A card
is one padded surface and can take a wide curve; a table is a stack of square rows behind a
shell that clips them, so past `lg` the top row's corner starts eating into the first cell's
text while every row underneath stays flat.
## The rules are hairlines
Every line _inside_ the table — under the header, between two rows, above the footer, and
between two column names — is one device pixel, `StyleSheet.hairlineWidth`, as the `List`'s
and the `Menu`'s are. Only the shell's own outline is a full point.
The rule between column names is the header's alone: a row's `gap` already says where one
field ends, and a grid ruled in both directions is a spreadsheet. It is drawn out of flow, so
it costs no width and a header column still starts on the same edge as the cells under it.
## See also
- **`List`** — for rows that are not a grid.
- **`Chip`** — what a status cell usually holds.
# Tabs
A row of tabs, and what each one shows.
## Import
```tsx
import { Tabs } from '@xaui/native/tabs'
```
## Anatomy
```tsx
…
…
```
- **`Tabs`** — holds what is chosen, and every trigger's measured rectangle.
- **`Tabs.List`** — the row the triggers sit in, and the box the indicator slides inside.
- **`Tabs.Trigger`** — one tab. It measures itself.
- **`Tabs.Label`** — its text.
- **`Tabs.Indicator`** — the one node that says which tab is chosen.
- **`Tabs.Content`** — what a tab shows.
## Usage
### Basic
```tsx
Tout
Non lus
…
```
A stringifiable trigger becomes its label (R3), which is what makes the common case one
line per tab.
### Three shapes
```tsx
…
```
| variant | track | indicator | chosen label |
| ----------- | ---------------- | ---------------- | ------------------- |
| `primary` | filled, rounded | a pill behind it | `segmentForeground` |
| `secondary` | a hairline under | a two-point rule | `foreground` |
| `light` | none | none | `accent` |
Three affordances rather than the same one louder, which is why the union is three rather
than the usual four.
`light` is the quietest a tab bar can be and still be one: no track, no rule, nothing but
the chosen tab's label going to the accent. The colour is the whole signal, which is why it
goes to the accent rather than to plain ink — a tab merely darker than its neighbours is not
chosen, it is just darker. It belongs over content that is already busy, and not where a bar
has to be found before it can be read.
`Tabs.Indicator` draws nothing under `light`, whether you leave it in or take it out.
### Widening the bar
```tsx
Tout
```
The list hugs its tabs by default. A tab bar as wide as the screen with three tabs in it is
a segmented control pretending to be a navigation bar, so widening it is a decision you
make rather than one you undo.
### No indicator
Leave `Tabs.Indicator` out and the label's colour is the only thing saying which tab is
chosen. That is a legitimate bar, and it is why the indicator is a slot rather than
something the list conjures.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `Tabs`
| prop | type | default | description |
| --------------- | ------------------------------------- | --------- | ------------------------------- |
| `variant` | `'primary' \| 'secondary' \| 'light'` | `primary` | A pill, a rule, or colour alone |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` | Trigger padding, gap and type |
| `radius` | `RadiusKey` | — | Overrides the track and pill |
| `color` | `string` | — | The tint (R7) — a raw value |
| `value` | `string` | — | Controlled |
| `defaultValue` | `string` | — | Uncontrolled |
| `onValueChange` | `(value: string) => void` | — | Fires on every choice |
| `isDisabled` | `boolean` | `false` | Dims the bar, stops every tab |
No `xs`. A tab is a target before it is a label, and at that height there is nothing left
of it.
### `Tabs.Trigger`
`value` (required), `isDisabled`, `asChild`. `children` may be a function taking
`{ isSelected, isPressed, isDisabled }`.
### `Tabs.Content`
`value` (required). **Mounted when its tab is chosen, absent otherwise** — a tab bar over
four screens of content should not have four screens of content mounted. A panel that must
keep its state across a switch is one you hold the state for, which is the same trade every
router makes.
## Motion
The indicator is **one node sliding**, not a border on each tab appearing and disappearing.
The triggers publish their rectangles on layout, the root keeps them, and the indicator
springs between them on the UI thread — so it keeps travelling while whatever the new tab
shows is mounting.
Softer than the chevron's spring: damping 20 against stiffness 220 at mass 0.6. That one
turns 180 degrees and must not overshoot; this one slides a few dozen points, and a touch
of overshoot is what makes it feel attached to the press.
**The first placement jumps rather than springing.** Animating it would slide the pill in
from the start of the row on mount, which reads as the tab bar arranging itself rather than
as a control at rest. Until a rectangle exists the indicator renders at zero opacity, so
there is no flash at the start of the row either.
## Not here yet
**A scrollable list.** The reference implementation's `Tabs.ScrollView` centres the chosen tab when the bar
overflows, and that means the indicator has to account for a scroll offset the triggers'
own layout does not report. Worth its own change.
**`Tabs.Separator`.** A hairline between tabs in the `secondary` shape.
## Accessibility
The list is a `tablist`, each trigger a `tab` carrying `selected`, each panel a `tab`.
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| -------------------------- | ---------------------------------- |
| ` ` | one `` per tab |
| `activeIndex` | `value` — a string, not a position |
| `onIndexChange` | `onValueChange` |
| `variant="segmented"` | `variant="primary"` |
| `variant="underline"` | `variant="secondary"` |
| `customAppearance={{ … }}` | `style` on the slot that key named |
**A tab is named, not numbered.** An index breaks the moment a tab is inserted, and it is
what made the legacy component's `activeIndex` a prop nobody could hold correctly.
# TagGroup
A wrapping set of tags you can turn on, and take off.
## Import
```tsx
import { TagGroup } from '@xaui/native/tag-group'
```
## Anatomy
```tsx
…
```
- **`TagGroup`** — what is selected, what is disabled, and every slot's resolved style.
- **`TagGroup.List`** — the wrapping row.
- **`TagGroup.Item`** — one tag.
- **`TagGroup.ItemLabel`** — its text.
- **`TagGroup.ItemRemoveButton`** — takes it off.
## This is not a row of `Chip`s
A **chip** is a piece of metadata that is always the same. A **tag** is one you can turn on,
take off, or both.
The selection state and the removal are the component; the pill around them is the least of
it. That is also why the two do not share a recipe — a chip has ten variants because it
reports an intent, and a tag has two grounds because it reports nothing at all until it is
selected.
## Usage
### Selectable
```tsx
Français
```
A stringifiable child becomes the label (R3). `selectionMode` is `'none'` by default: a
group of tags is a set of labels until you say otherwise.
### Removable
```tsx
setTags(t => t.filter(x => x !== id))}>
Français
```
**The cross renders nothing without an `onRemove`.** Removing a tag is your list changing,
and a cross that appeared to work while the list stayed put would be worse than one that is
plainly not there.
It is also written out rather than drawn by the item, because a tag you can turn on and a
tag you can take off are different controls and most groups are only one of the two.
### Style as props
```tsx
```
Full RN names, full RN values (R14). Every node takes them.
## Props
### `TagGroup`
| prop | type | default |
| --------------------- | ----------------------------------- | --------- |
| `variant` | `'default' \| 'surface'` | `default` |
| `size` | `'sm' \| 'md' \| 'lg'` | `md` |
| `radius` | `RadiusKey` | — |
| `color` | `string` | — |
| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `none` |
| `selectedKeys` | `readonly string[]` | — |
| `defaultSelectedKeys` | `readonly string[]` | — |
| `onSelectionChange` | `(keys: readonly string[]) => void` | — |
| `onRemove` | `(id: string) => void` | — |
| `disabledKeys` | `readonly string[]` | `[]` |
| `isDisabled` | `boolean` | `false` |
| `isDeselectable` | `boolean` | `true` |
### `TagGroup.Item`
`id` (required), `isDisabled`, `asChild`. `children` may be a function taking
`{ isSelected, isPressed, isDisabled }`.
## Two grounds, not two emphases
`default` is the theme's neutral fill; `surface` is the card colour. **They swap** so a tag
never disappears into what is behind it — a group on a card wants `default`, a group on the
page wants `surface`.
A selected tag leaves both and takes the accent's soft slice, which is the only place this
component uses colour.
## Wrapping is the point
A tag group is a set of the same kind of thing, and a set that scrolls sideways hides how
many of it there are — which is the one fact a reader wants from a row of tags.
## The selection rule
One pure function, and it returns the list **unchanged** whenever a press changes nothing:
`'none'` refuses every press, `'single'` on the already-selected tag clears it unless that
would empty a group the caller asked to keep one. `useControllableState` drops a set to the
value it already holds, so `onSelectionChange` never fires for a change that did not happen.
Ten tests cover it.
## Accessibility
The list is a `list`, each tag a `button` carrying `selected` and `disabled`. The cross
takes the tag's own `isDisabled` — a disabled tag that can still be removed is not disabled
— and warns in development when it has no `accessibilityLabel`, because a cross announces
as nothing on its own.
# TextArea
A multiline field, with the label, the hint and the error that make it usable.
> **Not part of the 1.0 core.** The fifteen are listed in the plan and this is not one of
> them; it ships under `1.x` as a P5 component.
## Import
```tsx
import { TextArea } from '@xaui/native/text-area'
```
## Anatomy
```tsx
```
### It is a `TextField`
Not "like" one — it **is** one. `TextArea` renders the `TextField`'s root: the same recipe, the
same resolved context, the same four variants, the same `size`, `radius`, `color`,
`labelPlacement`, `isInvalid` and `isDisabled`. `TextArea.Label`, `TextArea.Description` and
`TextArea.Error` are literally the `TextField`'s slots, re-exported rather than wrapped — a
wrapper would add three components to the tree to change a `displayName`, and the string it
would change is the one telling you the truth.
**Only `TextArea.Field` differs**, by three things: `multiline`, the text pinned to the top,
and a height counted in lines.
That is also the reference implementation's answer — its `TextArea`
is twenty lines rendering its `TextField` with the same three defaults. A component of its own
is what a caller looks for; sharing every line of it is what keeps the two from drifting.
So **everything in [`input.md`](../text-field/text-field.md) applies here**, and this page only covers
what is added.
## Usage
### Basic
```tsx
```
### `rows` and `maxRows`
```tsx
```
`rows` is the starting height, in lines. `maxRows` is the ceiling: past it the field stops
growing and scrolls. **Unset, it grows for as long as the text does** — and has nothing to
scroll, which is why `scrollEnabled` follows `maxRows` rather than being a prop of its own.
Both sit on the **root** rather than on the field, for the reason `size` does: the root is
where the field's shape is decided, and the slot reads what it resolved.
**They are raw values** (R6), like `color`: they resolve outside the style cache from the
line height the size chose, which is what lets `rows={7}` exist without seven entries in the
cache.
| `size` | Line | Padding | `rows={3}` | `rows={4}` |
| ------ | ---- | ------- | ---------- | ---------- |
| `xs` | 20 | 6 | 72 | 92 |
| `sm` | 24 | 8 | 88 | 112 |
| `md` | 24 | 8 | 88 | 112 |
| `lg` | 28 | 10 | 104 | 132 |
### A fixed height
The reference implementation's text area is a **fixed** 128 that scrolls rather than one that grows. That is a
style prop away (R14), rather than a second API:
```tsx
```
### Everything the `TextField` does
```tsx
```
`labelPlacement="inside"` composes: the label keeps its room at the top and the first line
starts below it, because the four inside-label compounds write the same `paddingTop` to the
text area as they do to the field.
## Props
### `TextArea`
Everything the [`TextField` root](../text-field/text-field.md#input) accepts, plus:
| Prop | Type | Default | Notes |
| --------- | -------- | ------- | ---------------------------------------------- |
| `rows` | `number` | `3` | The starting height, in lines |
| `maxRows` | `number` | — | The ceiling. Past it the field scrolls instead |
### `TextArea.Field`
Everything [`TextField.Field`](../text-field/text-field.md#inputfield) accepts except `multiline`, which
it sets. `scrollEnabled` defaults to whether `maxRows` was given.
### `TextArea.Label`, `TextArea.Description`, `TextArea.Error`
The `TextField`'s, unchanged. See [`input.md`](../text-field/text-field.md).
## Extending it
`useTextArea()` carries **only** the two row counts — the one thing the `TextField`'s own
context cannot, because `rows` is not the `TextField`'s business and a prop that does nothing in
the common case reads as broken. Everything visual comes from `useTextField()`, because the
styles were resolved there.
```tsx
import { useTextField } from '@xaui/native/text-field'
import { useTextArea } from '@xaui/native/text-area'
```
## Accessibility
The `TextField`'s, unchanged: the wrapper has no role, the field points at the label with
`aria-labelledby` and at the description with `aria-describedby`, and `aria-invalid` follows
`isInvalid`. See [`input.md`](../text-field/text-field.md#accessibility).
## Migration from `@xaui/native-legacy`
| Legacy | v1 |
| -------------------- | ----------------------------------------------------------- |
| `