0.9.1
Stable@xaui/native@0.9.1
Patch Changes
-
7e04096:
Accordion— Item · Trigger · Indicator · ContentP5.11, over the legacy
ExpansionPanel. The reference implementation calls itaccordionand so does this, which is also what the roadmap row now says.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 deliberately: 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 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. The container carries the same transition, because without it the accordion's own height jumps to its new total in one frame while the rows inside it are still animating.
The variant table is the
Card's, token for token. An accordion indefaultis a card with rows in it, and two containers that look alike but are declared apart drift — the drift showing up as an accordion sitting on a card with a fill one step off it.ghostis the default and is the reference implementation's own: rows separated by hairlines, on whatever page they sit on.The separators are the root's, drawn between its children. A row that drew its own would draw one under the last item too, and every accordion would start by hiding it. They come off
Children.toArray, which drops nulls, so a conditionally rendered row cannot leave a hairline hanging where nothing is.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
selectionModeneeds to mean anything. The whole rule is a pure function with thirteen tests, including the two cases where it returns the value unchanged — a press refused underisCollapsible={false}must not fireonValueChangefor a change that did not happen.ChevronDownIconmoves from theSelect's folder tosystem/iconand is exported from@xaui/native/system. Two components draw it now, which is §2 bis exactly: promotion at the second use, never by anticipation. -
ab573da: feat(agenda-calendar): one week, and what is on it
P5.26c. The row of marks is the whole difference: a strip of seven numbers is a date picker, and a strip of seven numbers with marks under some of them is an agenda.
The cells are the
Calendar's own style, resolved throughcalendarReciperather 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.It is a component rather than a
layoutprop on theCalendarbecause it steps by weeks. A different unit means different state under it, andlayout="week"would have been a prop that changes what another prop means. Everything genuinely shared 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 the bounds make a day inert, and the chevrons go dead when the week they would reach has none left.
Today moves the strip; it does not choose today. The two are one press apart, and a button that quietly answered the question would be a button you cannot use to look. It goes dead — and now reads dead — while this week is already the one showing.
eventsis a list read by day and turned into a set once per change, rather than a scan per cell. The title names the month of the week's middle day, which is always the majority month of a seven-day window and the only rule that does not call a week with six September days in it "August". -
846e770: feat(agenda-calendar): the Today pill answers the variant
The four variants aimed the chosen day and nothing else, so a strip whose selected day was a soft wash sat under a pill that had kept a hard accent border — two levels of emphasis on one card, from one prop. The pill now resolves from the same
variant: outlined with the word in the accent onprimary, a soft accent fill onsecondary, a neutral one ontertiary, the bare word onghost.primaryis what the pill already looked like, so nothing moves for a caller who never set the prop.The emphasis runs the other way round from a
Button's, and on purpose:primaryoutlines rather than fills. The pill sits between two bare chevrons, and the filled accent that makes aButtonprimary would read there as the primary action of the whole card, so the accent goes on the word instead — which is where the strip's own accent already is.A raw
colorfollows the variant rather than one fixed role: the border and the word onprimary, the fill onsecondaryandtertiary, the word onghost. That isresolveTintmapping the roles the variant declared, with nothing per-variant to say about whatcolormeans. -
d5461ae: feat(alert): the v1
Alert— P3.6A message the interface has to make sure is read. Compound root plus five slots:
Alert.Icon,Alert.Content,Alert.Title,Alert.DescriptionandAlert.Close, laid out as a row of three columns spaced by the root'sgapalone.Nine variants: the
Card'ssurfacefor the neutral level — the reference implementation's alert root, token for token, shadow included — and theChip's status ladder for the rest, each family in its full and soft slice.Visually aligned with the reference implementation: 12pt of padding, a 12pt gap, a 24pt radius, a 16/24 title above a 14/20 description and an 18pt icon at
md. The icon's optical offset is derived from the title's leading rather than hard-coded, so it stays right at all four sizes.The root is never a control — no
isPressable, no press behaviour on the type. What you press isAlert.Close, which now comes from a sharedsystem/close-button: theChip's close became its second use, so its press state, grown touch target, missing-label warning and built-in cross are written once and both components are five-line call sites.Also fixes an inference bug in
createRecipe: acompoundVariantsentry declared the variant union instead of selecting from it, so a recipe whose only compound was{ when: { variant: 'default' } }rejected every other variant at the call site.Alert.IconpicksIcon's forms one by one, so the union survives.IconPropsbecame a discriminated union of its three forms, and a non-distributivePickover it merged them back into a single shape whereasandsourceare both optional — which stopped type-checking the moment both changes met onmain, and would have let<Alert.Icon as={Check} source={png} />compile with one of the two silently dropped. The type now distributes thePick, and the slot renders one<Icon>per form inIcon's own runtime precedence rather than one call carrying all three. -
c4c4657: Fix what the blocking P2 API review found, before fifteen components copy it.
require()failed on every subpath.exports.requirepointed at the ESM build while the CJS build was produced and never referenced, so in a"type": "module"package everyrequire('@xaui/native')threw aSyntaxError. Both packages now declare the full dual form, with types per condition —.d.tsunderimport,.d.ctsunderrequire— so a CommonJS consumer no longer type-checks against the ESM declarations.Overlays painted outside rounded corners.
HighlightandRippleare absolute fills with square corners, and every control in the library is rounded. The clip existed only forscale-ripple, and only on the animated branch; it now applies on both branches whenever a default overlay is mounted — and only then, so a root without one can still let a child overflow.accessibilityStatewas replaced instead of merged onButton. A caller addingexpandedorselectedsilently eraseddisabledandbusy, and a screen reader stopped announcing a disabled button.defaultVariantsnarrowed a recipe's wholeVarianttype to the single value named in it, making every other variant a type error at the call site.NoInferin the engine removes the cast each of the forty-seven components would otherwise have carried. -
65dbac6: feat(autocomplete): the field gets its label, its hint and its error
AutocompleteandComboboxhad no label. They are fields — a combobox sits on the line where aTextFieldwould — so a form using one had to label it from outside the component, which is a second column to keep in step and a label a screen reader never associates with the control.The root is now the column, the
DatePicker's shape: aViewthat stacksAutocomplete.Label, the trigger andAutocomplete.Description/.Errorwith onegap, so JSX order is screen order.Comboboxgets the same three slots under its own name — its root is theAutocomplete's, so the column comes with it, and a combobox labelled differently from the text field beside it is exactly the drift this sharing prevents.The column, the label and the help lines resolve through
textFieldReciperather than a second table, so they are theTextField's token for token.isInvalidturns the label and the descriptiondanger; it never mountsAutocomplete.Error, which stays yours to write.The trigger — and the
Combobox's input — points at the two slots througharia-labelledbyandaria-describedby, so what is announced is what the field asked for rather than only the row that happens to be chosen. Mounting the slots is all it takes; your own is still the last word.disablednow dims the column once instead of dimming the trigger a second time inside it. The root takesref,style,asChildand R14's style props for that column; the control keeps its own onAutocomplete.Trigger. -
4e1233f:
Autocomplete— a field that opens a list you search.It is not a
Select, and it wears its clothes. 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.So the two share their style by construction rather than by coincidence: 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.Autocomplete.Searchlives inside the panel and is pinned above its scroller, so it stays put while the results move under it. It takes focus as the panel opens — one you have to tap twice before you can type into it is a select with a spare row — and the query goes with the panel: closing clears it, because a search that survived its own closing would leave the list already filtered by a word nobody can see.Matching folds diacritics and drops case both ways (
genevefindsGenève), and matches any word rather than the first: a prefix match on "New York" refuses "york", and a long list is searched by whichever word someone remembers.Filtering drops rows off the elements, before any mounts, and only the panel's direct children. Walking deeper to read a label changes nothing; dropping a row nested inside a caller's own component would mean rebuilding that component's children for it, and a filter that silently rewrote a caller's tree is worse than one that leaves it alone.
Autocomplete.Emptyrenders 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.The trigger announces itself as a
comboboxrather than a button: it opens a list you type into, and that is the role that says so.collectItemLabelsmoves toutils/item-labels— theSelectand theAutocompletehave the same trigger, the same portal and the same problem. §2 bis, promotion at the second use. -
9f3cde7: feat(avatar): the v1
Avatar— Image · Fallback · Initials, the fallback as a layerThe eleventh entry of the core. The fallback is not a state, it is the layer underneath.
Avatar.Imageis absolutely positioned overAvatar.Fallback, and anImagewith 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, noonErrorto 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 slots is therefore free.variantis theChip's eleven names, meaning here what they mean there — an avatar is a token about a person or a thing, which is the category theChipestablished. The three status families are present 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'svariant × colormatrix said once.sizesets both sides, because an avatar is a square before it is a circle — 32, 40, 48, 64, The reference implementation's three steps plus the one our ladder adds below them. The glyph inside the fallback runs ahead of the initials at the top of the scale, because two letters fill a circle that one person-icon has to sit inside with air around it.radiusdefaults tofull, where the reference implementation fixes one large radius for all three sizes — which makes their small avatars round and their large ones squircles.No default glyph. XAUI publishes no icon set, so the mark is always the caller's. What
Avatar.Fallbackdoes instead is publish the frame's resolved size and colour toIconContext, so anIconwritten inside it needs no props at all.The photo fades in over 200ms on
onLoad— the reference implementation's timing — driven by a shared value rather than a mount animation, because the node has to be mounted from the first render or it never fetches.animation={false}skips it and mounts no worklet. -
4df7f3a: feat(badge): the v1
Badge— a count, a dot, and the corner it hangs offThe twelfth entry of the core, and one node with no slots, per the plan: whatever is inside a badge is one line of two or three characters, and a slot would be a name for a
Textthe component can just as well insert itself (R3).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 theminWidthequal 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 heights sit below theChip's, 16/18/20/24 against 20/24/28/36.dangeris 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<Badge>3</Badge>means.isDotis the bare circle, on its own diameter ladder (6, 8, 10, 12) rather than the height, because a 20pt circle beside a 16pt icon is not a dot. It reaches the recipe as adotaxis selected by the resolved size: an axis left unselected contributes nothing, which is exactly "this badge has a label" — where a{ true, false }axis would have needed a branch with nothing to say and asize × isDotcompound would have been sixteen entries for four measurements.placementmakes the parent whatever the badge decorates: absolutely positioned in that corner, pulled out by half its own height on each axis so its centre lands on the corner it marks. The offset is derived fromsize, which is why it is a prop and not four style keys at the call site — and the keys arestartandend(R13), so a trailing-corner badge mirrors in RTL. The insets are computed outside the style cache, and have to be: in flow the node isposition: 'relative', where an inset is a nudge rather than a placement, so a cachedtop: -10would shift every badge that has no placement at all.placementInsetsis the one pure function here, and it has the one test file. -
15b01ea: Move the pre-release line from
alphatobeta..changeset/pre.jsonnow carries the tagbeta, so both packages are versioned0.9.x-beta.xand every publish lands on thebetadist-tag:pnpm add @xaui/native@betais the opt-in from here on. Thealphadist-tag is frozen on the last0.9.1-alpha.xpublish and no longer moves, andlatestis untouched — it still points at@xaui/native@0.2.8and@xaui/hybrid@0.0.14until1.0.0.Only the
tagfield changed.initialVersionsand the list of consumed changesets are kept as they were, which is what apre exit+pre enter betawould have thrown away — the nextchangeset versionwould then have replayed every entry into the changelogs. -
8de0808:
BottomSheetgets a reduced state.collapsedHeight={200}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,defaultExpandedandonExpandedChangecontrol it the wayisOpencontrols the other.These are not snap points — two states, not an array of positions.
Where the sheet cuts comes from
BottomSheet.Summary, a new slot: it is<summary>to the sheet's<details>, the part that survives rather than a different view for the reduced state. It renders in both, and reports where its bottom edge falls so that whatever sits above it — a handle, usually — is counted too. The sheet adds its own bottom padding back onto that edge: cutting on the summary's last pixel leaves the reduced sheet with air above the handle and none under the last line, the text against the screen edge and under the gesture bar on a phone that has one.collapsedHeightis not extended that way — it is a number written against a sheet someone was looking at — and it stays as the fallback for a sheet with no natural seam, the summary winning when both are given.Either way the sheet is not re-laid out. It is the same box at its full height, moved further down, so the tail slides off the bottom of the screen and comes back untouched.
A drag that was not decisive puts the sheet back. Decisive down goes one state down, unless the throw was aimed past the reduced notch, in which case it dismisses: dragging a sheet the whole way to the bottom and having it stop half open reads as a refusal. Decisive up expands. Without a
collapsedHeightnone of this applies and the sheet behaves exactly as before.BottomSheet.Handlebecomes a real control on a collapsible sheet, the way anAccordion.Triggeris — 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 warns in development without anaccessibilityLabel. -
ef43606:
BottomSheet— Trigger · Overlay · Content · Handle · Title · Description · CloseBuilt on this library's own peers rather than on
@gorhom/bottom-sheet, which is what The reference implementation wraps. 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 it costs is their snap points and their scroll integration — both worth having, and both worth their own change rather than a dependency.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, so 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.
Far enough or fast enough. Past
dismissThresholdof its own 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. The drag is downward only: a sheet dragged up is already against the top of its own content, and letting it stretch there is a rubber-band nobody asked for.Two separate refusals.
isSwipeable={false}on the content andisDismissable={false}on the overlay, because a sheet that can be tapped away but not dragged is a real design and so is the reverse.BottomSheet.Handleis written by the caller. It is the only thing telling a reader the sheet can be dragged — the gesture has no other affordance — so a sheet with the drag turned off should not be advertising it.radiusmoves the top corners only, which is why this component does not useradiusAxis: that helper writesborderRadius, and a sheet's lower corners are off the screen. Rounding them would put two arcs against a straight edge nobody can see.It completes what the
Dialogstarted:Select.ContentandMenu.Contentare written around apresentationprop that needed both. -
13acb91: feat(time-field): a time, typed
TimeFieldis theDateField's sibling: the sameTextFieldroot, the same three text slots, and one representation — the digits, in order — thatmaskTimeis the only thing to turn into text. The hour cycle comes out ofIntl.The period is a toggle rather than two letters typed into the box, because the keyboard a time field opens is a number pad and cannot produce them.
TimeField.Periodrenders nothing on a twenty-four-hour field, so the same JSX serves both. -
c459084: feat(calendar): a month, and the day chosen in it
P5.26b, and the first of the date family: the
DatePicker, theAgendaCalendar, theRangeCalendarand theDateRangePickerare all this grid with something around it.The month on screen is state of its own, separate from the chosen day. Paging through months is not choosing: 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.
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. Days from the months either side fill the ends, muted but still choosable: a calendar that refused the 1st of next month would be refusing a date you can see.
Calendar.Gridtakes 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 to compose against —asChildmerges into one element and a slot list cannot enumerate a month. It stays two lines at the call site because a day is a date plus the calendar around it:Calendar.Dayreads chosen, outside-the-month, out-of-bounds and today off its owndate.The chosen day is
bgSelected/fgSelectedrather than a variant axis — theCheckbox's roles for theCheckbox's reason: forty-two cells share one resolution, and a rawcolorwritten as an axis would stop reaching the chosen day the moment it became the chosen one.The chevrons go dead at the bounds. A step that would land on a month with no selectable day has nothing to show, and a chevron that stays lit while it stops working is the worst of the three options. Bounds compare by day, not by instant: a
maxValuewritten asnew Date()carries the current time, and an instant comparison would refuse the rest of today.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 — with that list as the fallback, not the source.utils/dates.tsis new and tested, twenty-eight cases. Two of its functions exist because the obvious version is wrong:addMonthsclamps to the end of the target month, since January the 31st plus a month is the 31st of February andDaterolls that to the 3rd of March; andaddDaysgoes through the day-of-month rather than through milliseconds, since a day is not always 86 400 seconds and adding that many across a daylight-saving boundary lands an hour into the day before. -
1555901:
Card— the v1 surface, and the control it becomes.A compound root with five slots —
Header,Body,Footer,Title,Description— on the same shape as theButton: the recipe resolves once at the root and publishes the resolved styles, every node takes its own style props (R14),asChildmerges into the caller's element, and the context hook is exported so a third party can add a slot.variantnarrows the shared vocabulary to its four emphasis levels —default,secondary,tertiary,ghost— over the theme'ssurface*family, with the surface shadow on the one level that stands on the background.sizedrives padding, both gaps, the radius and the type of the two text slots, and never a height: a card is as tall as what it holds.isPressableturns the surface into aPressableFeedbackwith a press wash,accessibilityRole="button"and the shared scale.The rendering is the reference implementation's card measured —
mdis 16pt of padding, a 24pt radius, an 18/28 title inmediumover a 16/24 description, no border on a filled surface — reached through our own vocabulary rather than through their utility classes, and with the gaps the component owns instead of leaving to the call site.Also fixes a
NoInfergap in the recipe engine: acompoundVariantsentry naming one variant used to collapse the whole recipe's variant union to that single value.Card.Background— a photo, a gradient or a video behind the card. The root hoists it, so JSX order does not decide stacking: a background written after the header would otherwise cover it, which is the invisible ordering rule composition should not carry. It reuses the marking idiomPressableFeedbackuses for its overlays, andmarkBackgroundis exported so a third party's layer is not a second-class citizen.The clip lives on the layer rather than on the root:
overflow: 'hidden'cuts the node's own shadow on iOS, so clipping the card would cost adefaultone the elevation its variant just gave it.radiustherefore moves both slots together — a corner that moved only the root would round the card and leave its photo square. The reference implementation reaches the same feature through abackgroundprop and clips on both nodes, losing the shadow.The light
surfaceSecondarymoves up half a step,#f4f4f5→#ececee. It sat so close to thebackground(#fafafa) that asecondarycard on the page read as no card at all, andzinc[200]was alreadysurfaceTertiary— so the level between them was the only one left. It is the OKLab midpoint of the two, written in the source layer rather than added to the palette:PaletteShadeis derived fromzinc, so a150there would have claimed every other family has one too. -
1a5bc45: feat(chart):
Chart— the card a figure is read onA figure on a screen is a card with words around it, and those words are a title, a subtitle, a number and a legend. Every one of them is a
Textthat should take the theme's type rather than a prop on a figure — so they are slots:Chart.Header,Chart.Heading,Chart.Title,Chart.Description,Chart.Value,Chart.Legend,Chart.LegendItem,Chart.Footer.The frame owns the appearance and the figure takes it.
variant,sizeandcolorare 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 reasonChart.Legendcan exist rather than being a prop on a figure. A figure that names its own still wins, and one outside a frame is unchanged.seriesCountis 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.
labelsis the short form and children are the long one — a legend carrying a value beside each name, which is what a donut wants under it.Optional in both directions. A figure on its own draws no ground and belongs in whatever card the caller has. 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.
Chart.Headingexists forProgressBar.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. The recipe'srootslot is now the card and the plot's box isplot; the series ink moves to a slot of its own rather than riding on the root'scolor. -
8bbed5d: feat(charts):
LineChart,AreaChart,BarChart,PieChartandRadarChart— drawn hereP5.34, P5.34b, P5.34e, P5.34f and P5.34g. Nothing is imported to draw them.
react-native-svgis already an optional peer — theSelect's check and theIcon'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: its own idea of a colour, its own appearance blob, its own units, and an API that is the one place
variantandcolorcannot reach. Wrapping one means either exposing that blob — which iscustomAppearance, the thing R2 removed — or fighting it. The alternative considered was a native graphics engine as a peer dependency, which is a great deal of install for five figures.And the maths becomes tests.
chart-scale,chart-pathandchart-paletteare 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, a ramp that must not drift off its hue. Each of those is a test rather than a screenshot someone has to remember to look at — and two of them were bugs the tests found before the demo did.The API is chartkit's shape — rows of objects,
xKey, a key per series — withyKeysplural, 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 one 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 onChartPlot, which takes a render function.A shade per series, not a colour per series. The palette is walked out of one colour in OKLab lightness, and reduces chroma to stay in gamut rather than clamping channels — clamping gives away the hue, and a blue drifts several degrees towards cyan across a ramp. 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.
LineChart,AreaChartandBarChartare three files over oneChartPlot, the way theAutocompleteis a few files overselectRecipe. It owns the frame, the grid, the axes and both scales — point spacing for a line, band spacing for a bar, because a line inset by half a slot reads as cut off and a bar on the edge is half outside the plot.PieChartandRadarChartare square rather than framed, and take the palette and the ink to draw their own geometry.The axis is honest:
niceScalepicks a step from 1, 2, 2.5, 5 or 10 times a power of ten and then widens the domain to a multiple of it, rather than squeezing ticks into the data's own range — which is what produces an axis labelled 3.33, and how a top tick ends up below the tallest bar.The curve is Fritsch–Carlson: flat at every turning point, capped at three times the neighbouring slope elsewhere. A midpoint cubic 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.
No chart paints its own ground; the card around it and the legend beside it are the caller's.
tsup's DTS pass gets a larger heap, because the generic chart props push the default over. -
e1326cf: feat(checkbox): the v1
Checkbox— a box, a mark and the label that toggles itThe eighth entry of the core. The root is the row, not the box: it is the pressable, so tapping the label ticks the checkbox — which is the whole reason
Checkbox.Labelis a slot here rather than aTextyou put beside the component and wire up yourself. The reference implementation needs a second component (ControlField) for that; the plan's slots for this one are Indicator · Label, and this is why.R3 goes one step further than elsewhere: a stringifiable tree becomes the label and the root supplies the indicator, because a checkbox without a box is not a checkbox. Written with no children at all it is the box alone — the form a table row wants.
Selection is not a style axis. The fill and the mark are two slots the indicator mounts only while it is ticked, painted from two new roles —
bgSelectedandfgSelected. That keeps the cache at one entry per token combination instead of two, and it is what makescolorthe colour the box checks in: the tint pass re-runspaintand the states, never the axes, so a fill written as an axis would have snapped back to the accent the moment the box was ticked.RadioandSwitchneed the same pair, which is why the roles are in the engine rather than in this recipe.Three of the
Input's four levels, on the samefield*tokens —ghostis absent, because a box with no border and no fill is nothing at all — plus the four sizes,radius,isInvalid(which drops the resting fill and outranks the tint) andisDisabled.isIndeterminateis ours and not the reference implementation's: the legacy checkbox had it, a "select all" is what it is for, andaccessibilityState.checked: 'mixed'is something only the component can say. A press resolves it to selected rather than toggling into it.The check is drawn, not imported — two borders of an empty box, a quarter turn from where they look like a tick — so a checkbox works in a project that has installed no icon set. It is the
CloseButton's bargain. Children ofCheckbox.Indicatorreplace it and ride the same 120ms fade.Two corrections after seeing it on a device.
The mark is lifted to where it looks centred. The check is an "L" — a left border and a bottom border — rotated a quarter turn, and an L keeps its ink in one corner rather than in the middle of its box. Rotating about that box's centre therefore leaves the tick sitting low, and flexbox dutifully centres the box it no longer fills. Rotating by −45° maps a point to
(dy − dx)·√2/2, and across the two strokes that spansHat the top andH − t − Wat the bottom — an ink centre(H − t)·√2/4below the box centre, 1.06pt on a 24pt box. The recipe now lifts by exactly that.Both transforms moved into the recipe, and
checkbox.style.tsis gone with them:transformis a whole value, so the recipe's shallow merge replaces it rather than blending, and a rotation in a sheet plus a translation in the recipe would have dropped one of the two. The lift is derived fromsideandstroke, so it holds at every size.No
xs. That box was 16 points square with a 1.5pt stroke, and a tick drawn in a space that small stops reading as a tick. The touch target is the row rather than the box, so shrinking the box buys nothing a caller can press.smis the compact size. -
17993f6: feat(chip): the v1
Chip— P3.5A compact token — a status, a tag, a filter, a person. Compound root plus five slots:
Chip.Label,Chip.Icon,Chip.Dot,Chip.AvatarandChip.Close, spaced by the root alone, so JSX order is screen order and there is nostartContent/endContent.Eleven flat variants replace the reference implementation's
variant × colormatrix: theButton's five-step emphasis ladder plus the three status families it deliberately refused — a chip reports an outcome, sosuccess,warninganddangereach land here with their soft slice.Visually aligned with the reference implementation: 12pt of horizontal padding, a 14/20 label and a 28pt
md, with the height fixed rather than derived from vertical padding so a chip carrying an avatar still lines up with the one beside it.Chip.Closeis a control in its own right — its own press state, its ownhitSlop, and a cross it draws itself, so a dismissible chip needs no icon set installed.Also extracts the
radiusaxis, duplicated in every recipe that has one, intoradiusAxis()insystem/recipe/.Chip.Avataris pulled back into the capsule's rounded end. The root's horizontal padding is set for text — 12pt atmd— while the height leaves only 3pt above and below a 22pt avatar, so a face sat visibly pushed into the chip where a label beside it looked right. The slot now cancels the difference, which seats it concentrically with the rounded end: the capsule's cap is a circle of radiusheight / 2and the avatar is one of radiusdiameter / 2, so they share a centre only when the gap is equal on every side. It is the one margin on a slot in this component, and R4 is about spacing between slots rather than about cancelling the parent's padding —Chip.Avataris a leading slot by contract, which is what makes a leading-only correction sound.marginStart, so RTL follows (R13), and clamped at zero so a theme with tighter padding needs no pull at all. -
e218df1: feat(time-picker): a field that opens a clock
TimePicker's trigger is aSelect's trigger and its panel is aBottomSheet— a clock face is close to three hundred points square, which beside a field on a phone is the screen. What it adds is the dial: two rings on a twenty-four hour face, sixty marks and twelve labels on the minutes, and the hours handing over to the minutes on the first press.The geometry is
utils/clock.ts, tested — the quarter turn that puts twelve at the top, the sign that keeps it above the centre in coordinates that grow downwards, and the conversion fromatan2's own convention. -
2f05199: feat(close-button): the dismiss affordance, on its own
Chip,Alert,Dialog,PopoverandBottomSheetall have a close. Each is five lines over a shared base that owns the behaviour — its own press state, the grown touch target, the missing-label warning, the cross drawn from two rotated bars — and each hands that base the styles its own recipe resolved. What was missing is the standalone one: a dismiss on something the library does not own, a card header, a banner, a sheet of your own.The base is renamed, and that is the whole of the breaking change.
system/close-buttonnow exportsCloseButtonBaseandcloseButtonGeometry; the public component takes the nameCloseButtonin@xaui/native/close-button. Two things calledCloseButtonin one root barrel is not a naming preference, it is an ambiguous re-export — and the split is worth saying out loud anyway: a close inside a component takes that component's colours and that component's scale, soChip.Closereaches for the base, and dropping a dismiss into a layout reaches for the component. The five existing call sites move with it.The recipe is what the component adds. Four emphasis levels and no intent — dismissing is neither a success nor a danger, and the close that carries an intent is the one inside a component that has one.
secondaryis the neutral disc and the default, for the reason theDialoggives 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.ghostis the bare cross for a component already providing one.Four sizes on a 24 / 28 / 32 / 40 box,
mdbeing the reference implementation's measured and theDialog's. The bar is a ratio of the box rather than a table — a bar rotated a quarter turn spanslength / √2per axis, so it is twice as long as the cross looks — which makes it one cross at four sizes instead of four drawings of one. The stroke does not scale: it is the thickness theChip, theAlertand theDialogalready draw at, and crosses that thickened with their box would read as four different marks.No pressed colour, unlike every other control here. The base owns the press state, because a cross has to be a different target from the panel around it, so the root cannot resolve a colour for a state it does not know it is in. The press is the shared
PressableFeedbacktreatment — which is how every close in the library already reads. -
893f056: feat(combobox): a field you type in, over a list you must choose from
The
Autocompletewith the search moved into the trigger. In an autocomplete the control shows the chosen row and you type in a box inside the panel; here the field is the trigger, on the line where aTextFieldwould be, rather than a button that opens a search. That is the ARIA combobox, and it is the shape a form wants.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.
The panel, the rows and the empty line are the
Autocomplete's own objects, not copies —Combobox.ContentisAutocomplete.Content. A row is a row whichever field opened it, and a second set would drift into a form with two panels half a shade apart. The load-bearing half of that:Autocomplete.Contenttells its children apart by identity, so aCombobox.Itemthat were a different component would be sorted into "not a row", would never be filtered, and would never register its label. The root is that component's too, wrapped rather than aliased — hanging slots off the autocomplete's own function would overwriteAutocomplete.Triggerfor everyone.Three slots are this component's own, and each is what it is for a reason. The trigger is a
View, not aPressable: 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. The input fills the box and takes the trigger's own text style, so the control does not change size as you type. The chevron is a control where the autocomplete's is a decoration — that trigger is itself pressable, this one is a field that raises a keyboard, so the way into the list without typing has to be the chevron.filterItemsandmatchesQuerymove toutils/filter-items.tsbesidecollectItemLabels, which was promoted for the same pair one component earlier. §2 bis: promotion at the second use. -
855dfbc: feat(date-picker): a field that opens a month
P5.26, and it owns almost nothing — which is the design. The trigger is a
Select's trigger, the panel is aSelect's panel, and the grid is aCalendar, 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 it adds is the wiring, and every piece of it is a place two things could otherwise disagree: the day read into the field through
Intl, a panel that closes when a day is pressed, and one set of bounds that the field, the grid and the chevrons all read.DatePicker.Calendartakes theCalendar's props minus the ones the picker already owns, because two sources for one of them would be two answers to one question.The panel is as wide as the grid, not as wide as the field. A list is as wide as the field that opens it because its rows are that field's answers; a month grid is seven columns of a fixed cell, and squeezing it into a narrow field would crush the cells or clip the week. So
widthdefaults tocontent-fit, and the calendar is given an explicit7 × cellread off theCalendar's own ladder — a grid of seven percentage columns inside a box with no width of its own measures zero.The field's level is not the calendar's. A
ghostfield over aprimarycalendar is the ordinary case — the trigger is quiet on the form and the chosen day is not — sovariantdresses the field andcalendarVariantdresses the grid, whilecolorreaches both.The month on screen stays the calendar's own state. Opening the panel a second time after paging leaves you where you were, and choosing a day in another month still works.
closeOnSelectis on by default: a picker whose only job is one date has been answered the moment a day is pressed. Off, the caller writes their own footer under the grid throughDatePicker.Calendar's children.calendarRecipe's size table is now exported for the width above, alongside theList's, which theListGroupexports for its header inset — same reason, same shape. -
750a85a:
Dialog.Closedraws a cross when it is empty, like the reference implementation's.It was a bare pressable that rendered whatever it was given and nothing when it was given nothing, so
<Dialog.Close />— the first line of the reference implementation's own anatomy — put an invisible 32 points in the corner. It now readssystem/close-button, which draws the cross from two rotated bars, and the dialog's recipe resolves the box and the bar the wayChip.Closealready did.The measurements are theirs, read off their CSS rather than guessed: a 32-point disc (
height: calc(var(--spacing) * 8),aspect-ratio: 1), filled withdefaultbecause theirCloseButtonis atertiarybutton, and amutedcross inside it.asChildis unchanged in behaviour and better in two details: the 32-point box is not forced onto the element you hand it, and the missing-label warning no longer fires on<Dialog.Close asChild><Button>Compris</Button></Dialog.Close>, where the label is the button's own text. That second fix is inCloseButtonand reaches every consumer.Also exports
SliderValue, whichSliderProps.valueandonValueChangeboth name and which no consumer could import. -
797ea98:
Dialog— Trigger · Overlay · Content · Title · Description · CloseThe
Popoverwithout an anchor. Same portal, same context re-provision, same overlay keyframes; none of the measuring pass, the host origin or the collision flip, because a centred box has nothing to be measured against.Two things it adds.
The backdrop dims, where the
Popover's paints nothing until abackgroundColorsays so. 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.isDismissable={false}is for one that must be answered rather than escaped.It grows from its own centre, 200 ms from
scale: 0.94. 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.The content is two layers, and the outer one is not decoration: a centred box cannot also be the thing that centres it. The outer layer fills the portal and does the centring, the panel is the box, and the outer one takes no touches — so a press that misses the panel reaches the overlay under it and closes the dialog.
No
variant: the question a dialog asks is in its words, not in its fill.It also unblocks the
presentationprop thatSelect.ContentandMenu.Contentare written around but cannot offer — the reference implementation has both, anddialogwas half of what was missing. -
7b0d12b: feat(divider): the v1
Divider— no variant, onealignSelffor both axesThe thirteenth entry of the core. One node and no slots: a rule is a filled box one point thick, and there is nothing inside it. A divider with a word across it is a
Rowholding two of these and aTypography— the composition the library already has. ADivider.Labelwould put a layout inside a line.No
variant, and this 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 — and 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 briefly had three, naming the three separator tokens, which is a shade of grey wearing a word.sizesays how heavy the rule is andcolorsays what colour it is, in React Native's own values; between them there is nothing a third name would add. The theme still sets the default — the rule paintsseparator.alignSelf: 'stretch'serves both orientations, and that one line is the whole mechanism: in aColumnthe cross axis is horizontal so a stretched child is full width, in aRowit is vertical so the same word makes a vertical rule full height, and on the axis the thickness fixes it is ignored. So there is nowidthorheightto keep in sync, and a horizontal divider written inside aRowcollapses on purpose rather than guessing.That is also why the recipe has no
size × orientationcompounds: thesizeaxis writes both keys blind and theorientationaxis, declared second, releases the wrong one. Four lines and two, instead of eight.asChildis there as R12 requires, and it earns its place on this component: anAnimated.Viewthat collapses a section takes the thickness and the ink from the recipe and the height from a shared value.sizeis the thickness —xsis the reference implementation'sthin, one device pixel, andlgis theirthick, six points. It defaults toxs, the one place in the library that does not default tomd: a rule you notice is a rule that is too thick. -
d042729:
DummyField— Label · Field · Value · Indicator · Description · ErrorP5.32, renamed from legacy
InputTrigger.- A pressable field container styled identically to a text input (
TextField), but non-editable and interactive throughPressableFeedback. - Renamed with the
*Fieldsuffix to matchTextField,MaskField,NumberField, andTimeField, avoiding name collisions with the*.Triggerslot vocabulary of overlay compounds (Select.Trigger,Popover.Trigger,Menu.Trigger). - Composable slot anatomy:
DummyField.Label,DummyField.Field,DummyField.Value,DummyField.Indicator,DummyField.Description,DummyField.Error. - Text children of
DummyField.Fieldare auto-wrapped inDummyField.Value(R3). - Supports
labelPlacement="inside"out of flow, four emphasis variants (primary,secondary,tertiary,ghost), four sizes (xs,sm,md,lg),isInvalid,isDisabled, raw tintcolor, and decorator padding insideFieldGroup.
- A pressable field container styled identically to a text input (
-
328e6db: feat(fab): the one thing to do on a screen, floating over it
Fabshares theButton's variant table token for token and not its recipe: a button is a row of text with padding, and this is a fixed square that carries a shadow at rest. Round or extended, three sizes measured from Material and the legacy, and aplacementthat pins it to the bottom in start/centre/end without a left or a right anywhere (R13).containsElementOfTypemoves frombutton.utils.tstoutils/children.ts— its second use is what promotes it (§2 bis) — and gains tests, including the one that says it looks no deeper than the direct children. -
8ed01e0: feat(timeline): what happened, in order, with a line through it
Timelinehas nogapon its root and cannot have one: the rail runs the full height of its entry, so a gap would be a break in the line.densityis the content's bottom padding, which is the one measurement that has to be in the right place.The rail is two halves rather than one line, which is what makes
alignwork: below the marker both are a share of the height so it centres, above it the upper half is a fixed inset so it sits level with the title's first line.statusnames what happened rather than how loud it is, and a tint reachesdefaultandcurrentonly — a timeline's greens and reds mean succeeded and failed. -
9512ee4: Fix a
FieldGroupprefix or suffix taking no touches on aprimaryfield, on Androidprimaryis the oneTextFieldvariant that lifts its field:theme.shadows.field, which carries anelevation. A decorator is laid over that field out of flow and was ordered byzIndexalone — and on Android an elevated sibling holds a native Z that a ReactzIndexdoes not outrank, so the field sat over the decorator in the order touches are dispatched and theTextInputunder it swallowed the press. The control was plainly visible and did nothing, onprimaryand on no other variant: aNumberField's stepper pair, a reveal toggle, a clear button.The decorator now carries an elevation of its own, one step above the field's own rather than a number written beside it, so the two cannot drift apart. It draws no shadow: Android takes an elevation shadow from a view's outline, and a decorator has no background to give it one.
-
4c65aa2: The
fieldradius aligns on the reference implementation's — 21 points becomes 12buildRadiusderived it asbase * 1.75, which on the default base of 12 put a 48-tall field at 21 — 87% of its geometric maximum, so it read as a gélule rather than as a rounded box. The reference implementation reaches 12 for the same control from the other side of the scale: their--radius-fieldis an alias of their--radius-xl, and their base is 8 where ours is 12.It coincides with
lgat the default base and stays its own key, because that is what lets a theme round its fields without rounding its cards.Only
Inputreads it — andTextAreathrough it, since that component has no recipe of its own and renders anInputRoot.InputOTPdeliberately does not: its box is very nearly square, where a wide field's corner is a shape nobody decided for it. -
de3781b: Allow the built-in TimePicker and DateTimePicker indicators to render without custom icon props.
-
41daa14: fix(native): preserve
Fab.Discoverytarget refs and backdrop dismissal while lifted -
a61b873: Add PhoneNumberField with a country prefix, searchable country sheet, national number editing and E.164 output.
libphonenumber-jsis a new optional peer dependency, needed only by this component. -
345b3e0:
Icon's R14 boundary moves from a comment into the typeIconPropsdeclared the style props andstylefor all three forms, but only thesourceform applies them — it is the one where we render the node. So this compiled and silently did nothing:<Icon as={Trash2} marginEnd={8} />The props are a discriminated union now:
as, a raw SVG child andsourceare mutually exclusive, and onlysourcecarries R14. The call above is a compile error that points atsizeandcolor, the levers the other two forms actually have.Iconalso gains the demo screen it never had — the three forms, the cascade from prop to slot to theme, and a raw SVG having its baked-in size overridden. Not having one is why the gap went unnoticed: nobody had tried writing a margin on an icon. -
133271c: feat(table): rows and columns, with a shell round them
Tableis three nodes and each earns its place: the shell clips and does not move, the scroll container moves, and the content inside it is allowed to be wider than the shell. Widths are declared by the column and read by position, so a cell and its column never name each other.The table never reorders anything — sorting reports the press and the caller sorts their own collection — and the third press clears the sort, so there is a way back to the table's own order.
Table.BodytakesasChildrather than avirtualizedprop: a table of ten thousand rows is aFlatList.utils/selection.tscarries the selection and sort arithmetic, tested — including the half-filled header box, the disabled row it must not count, and the keys chosen on another page it must not clear. -
60011be: Add
Iconto@xaui/native/system.An icon is a third-party component, so a slot context never reaches it and every call site ends up computing the colour by hand.
Iconcloses that: three forms — a component throughas(sizeandcolorinjected, covering Lucide, Ionicons and vector-icons), a rawreact-native-svgelement as children, or an image throughsource— all resolving the same way. An explicit prop, else what the surrounding slot published throughIconContext, else the theme.For a raw SVG the resolved values win over the element's own
width,heightandcolor: one arriving from a design tool carries a baked-in size, and inheriting the slot's instead is the point of wrapping it.react-native-svgstays an optional peer — nothing here imports it, the raw-SVG form only clones an element the caller already made. -
0c5435f: The
Input's column tightens by a point at every sizegapwas 4, 4, 6, 8 and is now 3, 3, 5, 7. A label, a field and a line of help are one thing the eye reads top to bottom, not three stacked blocks, and the whole spacing step let them drift far enough apart to read as a list.Quarter steps rather than a new scale:
spacingtakes a fraction, and theChipalready measures its dot and its cross that way. It stays agapon the root and not a margin on any slot (R4) — which is what keeps the space above and below the field identical, and what stops an omittedInput.Descriptionfrom leaving a hole behind it.TextAreainherits it, having no recipe of its own. -
2f8a9bd: feat(input-group):
InputGroup— a field with something beside itA glyph, a unit, a reveal toggle.
InputGroupgoes inside anInputand replaces nothing but the field: the column, the label, the hint, the error, the four variants, thesize, theradius, the tint, the focus,isInvalidandisDisabledall stay theInput's, and this root owns exactly one thing — how wide its two decorators turned out to be.The box is still the
TextInput.InputGroup.PrefixandInputGroup.Suffixare taken out of flow and laid over the field, so no wrapper borrows the border, the fill, the radius and the shadow: there is one box in the library and this is not a second one to keep in step. The field clears them by their measured width instead —paddingStartandpaddingEnd, logical edges (R13) — which is the same shapeTextAreauses forrows: a raw value the slot turns into a style, outside the cache (R6). A width takes as many values as there are decorators and could never be a cache key.isDecorativedoes the two things that belong together: touches pass through to the field underneath, and the content leaves the accessibility tree. It is off by default, because a suffix is most often a control and one that swallowed its own taps would be a reveal toggle you cannot press. A disabledInputtakes the touches from both decorators all the same.InputGroup.Iconis the slotButton,ChipandAlertalready have, and the one the reference implementation's component does not: a glyph one step above the field's type, in the theme'sfieldPlaceholder, so a form does not carry a hard-coded#888on every field.The
Input's recipe gains three slots —prefix,suffixandicon— because the size that decides the decorator's inset and the glyph's scale is the field's, and a group with an axis of its own would be a second answer to a question theInputhas already answered.Not one of the fifteen the 1.0 core is scoped to; recorded as P5.3.
-
94b4850: feat(input-otp):
InputOTP— a one-time code, one character to a boxNot one of the fifteen the 1.0 core is scoped to, so it ships as a P5 component under
1.x. Its API is theInput's: the same four levels over the theme'sfield*family, the samesize,radius,color,isInvalidandisDisabled.One hidden
TextInputholds the whole code, and the boxes are a rendering of that one string. Six focusable boxes is the design every OTP component starts with and 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 keystroke, a backspace, a paste and an autofilledone-time-codeall take the same path.Paste keeps only the code: a run of exactly
maxLengthdigits with no digit on either side, so "Your code is 482913, it expires in 10 minutes" yields482913and notYour c.InputOTP.Grouptakes a render function — the one slot in the library that does, because the number of children here ismaxLengthrather than markup.refis the imperative handle (focus,blur,clear) rather than the view, since those are the three things only the hidden input can do.Fifteen tests on the pure helpers —
buildSlots,extractPastedCode,isPaste. The component itself is verified by its demo screen, as every other one is.Three corrections after seeing it on a device.
The box takes the
lgradius, 12 points, notfield. 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 atmd, 36 by 40 atsm— 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: theirfieldradius is theirxl, and their scale's base is 8 where ours is 12.No
ghost. TheInputhas one and this does not, because the shape of the component is different: 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 theCheckboxhas noghosteither.No
xs. The box's width is the control height less one spacing step, soxswas 28 by 32 — a box that small still has to carry an 18pt character to stay legible, and 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.smis the compact size; below it, use fewer boxes rather than smaller ones. -
110dd81: feat(text-area):
TextArea— a multiline field, over theInputNot "like" an
Input— it is one.TextArearenders theInput's root: the same recipe, the same resolved context, the same four variants, the samesize,radius,color,labelPlacement,isInvalidandisDisabled.TextArea.Label,.Descriptionand.Errorare literally theInput's slots, re-exported rather than wrapped.Only
TextArea.Fielddiffers, by three things:multiline, the text pinned to the top, and a height counted in lines. That is the reference implementation's answer too — theirTextAreais twenty lines rendering theirInputwith the same three defaults.rows(default3) andmaxRowsare raw values (R6), likecolor: they resolve outside the style cache from the line height the size chose, sorows={7}costs no cache entry. PastmaxRowsthe field stops growing and scrolls; unset, it grows with the text and has nothing to scroll, which is whyscrollEnabledfollowsmaxRowsrather than being a prop of its own.The
Input's recipe gains atextAreaslot carrying only the delta — the line height, the vertical padding andtextAlignVertical— layered over the field's own style, so the colours, the border and the radius are resolved once for both. The four inside-label compounds write to it as well, solabelPlacement="inside"composes.Not one of the fifteen the 1.0 core is scoped to; recorded as P5.
-
4467295: feat(input): the v1
Input— P3.7A text field with the label, the hint and the error that make it usable. Compound root plus four slots:
Input.Label,Input.Field,Input.DescriptionandInput.Error.The root is the column, not the field.
Input.Fieldis theTextInput, which is what makes the three lines slots of one component rather than three components a form has to keep in step — and whyTextInputPropsare on the field rather than on the root.The first real use of the theme's
field*family, derived in P0 and unread since. Four variants, the library's emphasis levels narrowed like theCard's, splitting the reference implementation's two-nameprimary | secondaryby saying what each of their ends already is:primaryis their field fill plus the theme'sfieldshadow,secondarytheir neutral fill and the default here,tertiarythe border alone,ghostneither.Focus darkens the border towards the mode's ink —
fieldBorderFocus, no ring and no accent.isInvalidoutranks it, so a field that is both reads as wrong rather than as busy.labelPlacement="inside"lifts the label into the box. It is taken out of flow and placed against the box's own padding, so the JSX is identical either way and nothing is reparented; the field pays for the room and the box grows by the same amount.Visually aligned with the reference implementation: a 48pt minimum, 12pt of horizontal padding, a 16/24 label above the field and a 14/20 line below it at
md. The height is a minimum rather than fixed — the one place this component departs from theButton's rule, because amultilinefield holds the user's own text and has to grow.Adds a
borderFocusrole tosystem/recipe, so a state can read the variant's own focus colour the waybgPressedlets a pressedButtondarken its own fill — and so a rawcolorfollows the field into focus. -
863cc86:
TypographyandTextSpan— the first entry of the v1 coreTen roles, aligned with the reference implementation's
text:h1–h6,body,body-sm,body-xsandcode. Each role fixes size, line height, weight and family together, which is why there is nosizeprop and noweightprop — the combinations they allowed (a heading in a light weight, a caption in a display size) become unwritable rather than discouraged.TextSpanis a bare React NativeText. Nesting aTextinside aTextalready inherits font, size, weight and colour on both platforms, so a span needs no context to read and no role to resolve: the legacyTextSpanContextwas reimplementing the platform, and it is gone.Typographytherefore publishes no slot and does none of a span's work.Neither alignment nor truncation gets a prop.
textAlignis aTextStylekey that R14 already exposes, andnumberOfLinesis React Native's own — a prop of ours would be a second name for the same thing. -
22ea8e6: Remove
TimeField. A time typed into a box isMaskFieldwithmask="time", which carries the same mask engine behind one API instead of two, so the field had nothing of its own left.TimePicker— the dial — is untouched. -
c66bf88: Add
Fab.Menuat@xaui/native/fab: a FAB that opens its two or three actions as separate pills. The trigger is never re-parented — it measures itself and the actions are anchored to that rectangle — so the FAB stays exactly where the layout put it, where the legacyFabMenumoved it into the portal's own corner. -
b27e2c7: feat(date-time-picker): a field that opens a month, and then a clock
DateTimePickerowns nothing: the field is aSelect's trigger, the two steps are aTabs, the month is aCalendarand the dial is aTimePicker— four components rendered as themselves, and no recipe of its own.Two steps rather than two fields, because a moment is one value and a calendar and a clock will not fit on a phone together. Each half keeps the other, so the value is one moment being narrowed rather than two being collected.
TimePicker.Indicatornow readsIconContextrather than its own picker's context, which is what lets another field render it. -
d28f819: Leave the
betapre-release line: versions are plain again and publish onlatest.@xaui/nativeand@xaui/hybridwere versioned0.9.x-beta.xand published on thebetadist-tag only, solateststayed on the old API (@xaui/native@0.2.8,@xaui/hybrid@0.0.14) and a plainnpm iinstalled components this documentation does not describe. From this release the versions drop the-betasuffix, follow normal patch numbers, and publish onlatest, sopnpm add @xaui/nativeinstalls what is documented.The line is still pre-1.0: the API can change before
1.0.0, and the release notes say when it does. Thebetaandalphadist-tags stay on their last publishes and no longer move; drop them from your install commands. Projects that pin@xaui/native@^0.2.8are not affected. -
8d1f9b1: Add the virtualized
Listcomponent with themed compound rows and final-row-aware hairline separators. -
c34831d: feat(list):
ListGroup— the sectioned listThe settings screen: sections side by side, each under what its rows have in common, with the sentence underneath that says what the switch actually does.
It is a group of
Lists, not aListwith headings in it. A list 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, and a heading belongs outside them.ListGroup.Sectionexists 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). That ratio is the whole design.The header is inset by the row's own padding, read off the
List's size table rather than guessed, so the heading and the text it heads share a left edge; the footer is inset with it.ListGroup.HeadercarriesaccessibilityRole="header", which is what lets a screen reader jump between sections. The footer carries none — a footnote is prose.variant,size,radius,colorandhasSeparatorare handed down as defaults, and a list that names its own wins: a settings screen is uniform, and settingvarianton five lists is five chances to set it differently.isDisabledis the one that is not a default. AListoutside any group is unchanged;hasSeparatorloses its literal default so that an unset prop can still reach the group's.Nothing is walked and nothing is counted: the group publishes two gaps and a type scale, the sections are ordinary children, and one can be built out of something that is not a list.
For the record, since the name is theirs: The reference implementation's
ListGroupis ourList— a Surface container with Item · ItemPrefix · ItemContent · ItemTitle · ItemDescription · ItemSuffix, slot for slot. What ships here under that name is the thing neither of us had. -
cb76b65:
List— rows on a ground.List.ItemandList.ItemButtonwithItemPrefix,ItemContent,ItemTitle,ItemDescriptionandItemSuffix, on the anatomy the reference implementation'sListGroupuses.It is the
Accordionwith rows that do not open, and it reads the same ladder, insets its separators the same way and lifts the same one variant. 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 the container thatCard,Popover,AccordionandDialogare all waiting on, and 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 one too, and every list would start by hiding it. The fill is the root's for the same reason: a row painting its own would stack two where the hairline sits, and the hairline would vanish into the seam. The inset stops where the text starts, and
ghost, having no edge to be inset from, runs its rows and its hairlines the full width.It does not select. No
selectionMode, noselectedKeys: picking one of several things is whatSelectandMenuare, and 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 — aSwitchin its suffix — which says out loud what it does and is reachable as the control it actually is.ItemSuffixdraws 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.A plain row does nothing, and shows nothing. A list is not necessarily a list of buttons; most are a table of facts, and a row that lights up under a finger it never responds to is a promise the component does not keep. So
List.Itemis aView— no press state, no wash, no role — and a row you can press isList.ItemButton, used in its place. Structural rather than inferred: a single item that turned pressable when handed anonPresswould still be guessing, and the guess would be invisible in the JSX. -
4a14277:
StackandGridjoin the layout lotStackoverlays. The root is the containing block (position: relative) andStack.Itemis a layer taken out of the flow (position: absolute); where a layer sits is R14 —top,bottom,start,end,zIndex. The first child stays in the flow and gives the stack its size. Overlaying is composed rather than inferred: a stack that positioned every child but the first would have to guess which one sets the size, and would change meaning the day a caller reordered them.Gridlays out a fixed number of columns, wrapping, and measures its column width rather than expressing it as a percentage.width: '33.33%'resolves against the content box and knows nothing about the gaps, so three cells plus two gaps overflow their row. The root reads its own width and publishes the exact column width;Grid.Item span={n}covers several columns, gaps included.gapis the grid's own prop because the root has to read it to size the cells.Containerand the remaining legacyview/entries are not planned: they are R14 orStack. -
78813c4:
Menu— Trigger · Overlay · Content · Label · Group · Item · ItemTitle · ItemDescription · ItemIndicatorA list of actions anchored to whatever opened it, and the third component to read the anchored positioning extracted for the
Popover—utils/placement.ts,hooks/use-anchor-ref.ts,hooks/use-anchored-position.ts,system/anchored/. Nothing about the measuring pass, the host origin or the collision flip is written again here, which is the whole return on that extraction.The intent belongs to the row, not to the menu. A menu is the theme's floating surface like a popover, with no emphasis of its own — but one row in it can be the destructive one, and a list where "Supprimer" reads like "Renommer" is the list that gets misread.
dangerpaints the title and any icon in it and nothing else: a red row would read as an alert. The description stays muted whatever the intent, because a danger row says what it does in red once and a red sentence under it says it twice.Both faces of a row are resolved once on the root, so a menu of forty actions costs what a menu of two costs and no slot ever touches the recipe (R5).
Choosing a row closes the menu after the caller's
onPresshas run, in that order: a handler that reads the menu's state has to run while there is still a menu.closesOnPress={false}is for the row that toggles something the reader will want to toggle again.offsetdefaults to 6 where thePopover's is 9 — a menu belongs to the control it drops out of, and a popover belongs to nothing.Menu.Separator, 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, because a rule inset to the titles reads as belonging to the row under it and this one belongs to neither. Hidden from screen readers: announcing "separator" between every pair of actions is noise in the one place a menu has to be brisk. It is the menu's own trim rather than a
Divider, resolved on the root with everything else the panel reads.flex: 1cannot be written inside a panel that measures itselfMenu.ItemTitlehad it, and the whole menu rendered as a seventy-point capsule with no text in it.flex: 1isflexBasis: 0. The measuring pass asks the panel how wide it wants to be, so there is no definite width for a zero basis to grow into: the row's content size is nothing, the title collapses, and the panel holds that width. The reference implementation writesflex: 1on the same node and gets away with it because their measuring pass hands the panel a definite width — ours asks a question a zero basis cannot answer.flexGrow: 1, flexShrink: 1, flexBasis: 'auto'fills the row exactly the same once the width is known, and starts from the content rather than from zero.useAnchoredPositionnow says so where anyone writing the next anchored panel will read it.Menu.Contentalso takes a measure of its own, fifteen ems against thePopover's thirteen: a menu row is a title with an indicator beside it and sometimes a sentence under it, where a popover is prose alone.SubMenuis not here. The reference implementation ships it as its own component and it needs a second anchored panel whose trigger is a row of the first, which is worth its own change. -
337f1cb:
MorphButton— Collapsed · Expanded · Label · Title · Description · IconP5.35c, net new. A button that changes shape: a pill at rest, a card once it is open.
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, which is the
Accordion's rule applied to a control's own box: a card whose sentence arrives from the network grows with it, instead of being stuck at the size it had when it was measured.That is also why
PressableFeedbackgained alayoutprop. The box that travels is the pressable itself; a transition on a wrapper animates the wrapper's frame while the pressable inside it is already at its final size, so the content spills out of the shape for the length of the spring. The root 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
sizeandradiusto say what one derived value says once, andradiusstill overrides it.The measurements are on the faces, not on the box — the collapsed height and side inset on
Collapsed, the card's four-sided frame onExpanded. Since one face is mounted at a time, that removes thesize×isExpandedcompound the component would otherwise need.Three sizes, not four: a card that opens out of a 32-point control has less room than the padding it would need. The
Button's seven variants unchanged, its…Pressedtoken for the press rather than aHighlightoverlay, and the description turned down by opacity rather than recoloured — the ground is a rawcoloras often as it is a token.No
childrenToString(R3), and it 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. A missing face warns in development.isExpanded/defaultExpanded/onExpandedChange, controlled or not.useMorphButton()publishestoggle, so a close control inside the card costs no state. The faces fade from the second shape onwards — Reanimated runs anenteringanimation on the first mount as well, and without that every button on a screen would fade in as the screen arrived.Chart(Donut and Heatmap),ComposedChart,LinkButtonandBottomSheetInputare dropped on the roadmap in the same change. -
c251f16: Emit declarations with
tsc, not onerollup-plugin-dtsworkertsup'sdtsrolls all thirty-five entry points up in a single worker that holds the package's whole type graph at once — every component's props, andreact-native's.d.tsunder them. It crossed Node's default 4288 MB heap, and the worker does not fail gracefully: the JS build reports success, thenERR_WORKER_OUT_OF_MEMORYtakes the process down with an error that names no file.mainwent red on its own, and every CI job that runs@xaui/native#buildas a turbo dependency — Pack Uniqueness, ESLint, Type Check, Vitest — went down with it. The stopgap raised the ceiling withNODE_OPTIONS=--max-old-space-size=6144; the package gains roughly a component per branch, so the ceiling was going to be hit again.Declarations now come from
tsc --emitDeclarationOnly— a plain file-by-file emit with no rollup pass and no worker to run out of heap — andtooling/dual-dtsmirrors each emitted.d.tsto the.d.ctsthat therequirehalf of theexportsmap points at. The--max-old-space-sizeflag is gone from thebuildscript.What a consumer sees:
distnow carries a declaration file for every source module rather than one bundled.d.tsper entry point. Bothimportandrequiretype conditions still resolve to a real file on every subpath,pnpm pack:checkstill passes, andpnpm buildsucceeds on a default heap. -
f3c9d09:
NumberField— Label · Decrement · Field · Increment · Description · ErrorP5.3c, over the legacy
NumberInput, and named the wayTextFieldandMaskFieldare: the roadmap row saidNumberInput, and the rename that madeInputintoTextFieldapplies to it too.It is a
TextField. The root is that root, unchanged — the same recipe, the same four variants, the samesize,radius,color,labelPlacement,isInvalidandisDisabled— andLabel,.Descriptionand.Errorare theTextField's slots, re-exported rather than wrapped. Only the field differs, by reading a number out of what is typed into it. TheMaskField's arrangement exactly.Two representations, and the caret is what swaps them. Out of the field the value is written by
Intl, withformatOptionspassed through untouched — a currency, a unit, a fixed number of decimals. 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 own separators would make the first keystroke clear the box. On the way in the value is rewritten plainly, so nobody types a euro sign back in. A full stop passes as the decimal mark wherever the locale is not using it to group, so1.5is one and a half infr-FRand1.234is still a thousand-odd inde-DE.The bounds land when the reader leaves, not while they type.
min={10}and a reader on their way to15types a1first; clamping that would take the keyboard away from them. Until the field is leftonValueChangereports what is actually in the box, and the clamp falls on blur and on every press of a stepper. The value isnumber | null—null, neverNaN, because "not a number yet" is a state andNaNis a value that propagates.The steppers are
FieldGroupdecorators, likeTimeField.Period: that is what lays a control over a field and measures it, so the box stays theTextInputitself..Decrementtakes the leading edge and.Incrementthe trailing one, because the value sits between them. Each goes flat and stops taking presses when it has nowhere left to go — asked of the result rather than of the bound, so a value half a step short of the ceiling can still reach it. With no children each draws its own mark out of one bar, or two a quarter turn apart, the close button's construction, so the field works in a project that has installed no icon set.The engine is four pure functions with a test each —
parseNumber,formatNumber,clampNumber,stepNumber— re-exported from the subpath for a caller building a stepper of their own.stepNumberrounds to the precision of the numbers that built the value, theSlider's rounding for theSlider's reason: three steps of a tenth are0.3and not0.30000000000000004. -
064ee2a:
NumberPad,PagerandRatingP5.3d, P5.30 and P5.39.
NumberPad— Key · Backspace · Action · Label · Icon. The grid is data, not markup:1to9, the0and the backspace are not a decision a caller makes, so the root renders them. What is composed is the one free corner of the bottom row, and that is whatchildrenis; left out, the corner is still a cell, or the0slides to the start of its row and stops being the middle column.It draws no display — what the value looks like is the screen's, an
InputOTP's boxes or a row of dots. A masked PIN is therefore a composition rather than a prop:InputOTP.Valuetakes children that win over the box's own character.maxLengthclamps rather than truncating, and measures the insert whole, for the00key that would otherwise land halfway over the limit. A cell owns its press state, which the root cannot see, so the root resolves both faces and each cell picks — theMenu's arrangement, and what keeps a pad of eleven the cost of a pad of two. The bare cells read the page's foreground rather than the variant's: aprimarypad putsaccentForegroundon its digits, and a backspace with no ground of its own would take white on white.Pager— Content · Page · Indicator · Dot. Whole pages on either axis. A page is the track, measured, on both axes — and the measurement is the track's rather than the root's, because the indicator sits in the flow under the pages.It shares the paging arithmetic with the
Carouseland nothing else. A page is the whole track where a slide is a division of it, so this uses RN's ownpagingEnabled; the travel isscrollTo({ animated: true }), because a page's move is a whole viewport, which is the distance the platform's own pagers travel on that curve. The dot changes colour and does not stretch: a page control is a fixed row of marks, and a mark that grows moves the row's arithmetic under a reader counting it.Rating— Item · Icon. One component for the input and the average, because a mark's fill is a fraction: 4.3 shows three tenths of the fifth mark, where a boolean per mark would have had to round it. A mark is the same glyph twice — the neutral one sizes the mark, the filled one is pinned over it in a clip cut to the fraction — and which layer an instance is in comes from the layer rather than a prop, so the glyph is written once. A press readslocationXand rounds up, the only rounding that matches the gesture.PressableFeedbackis unchanged; thePagerand theRatinguseAnimated.ScrollViewandPressableFeedbackas they are. -
413b20f:
NumberStepper— Track · Decrement · Value · IncrementP5.3e, net new. The increment pair the legacy
Stepperis not — that one is a progress indicator, and this is a control.It is not a
NumberFieldwithout its box. A field is typed into and this is not: no keyboard, no caret, no parse, noisInvalid, and no bounds to apply late, because 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 — which is whyparseNumber,formatNumber,clampNumberandstepNumbermove toutils/number.tsin this change, at their second use and not by anticipation (§2 bis). Both components re-export the six from their own subpath, so a caller never reaches intoutils/, which is private.The track is a slot, and it is written first. Out of flow and painted behind everything after it, so its place in the JSX is what puts it under the rest —
Slider.Track's arrangement. 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".The variant paints the buttons, because they are what a finger is aimed at. Four emphasis levels, no intents — a stepper reports nothing.
secondaryis the default and the one departure from the vocabulary table: it namessurfacerather thandefault, because adefaultbutton on adefaultSoftpill is two greys a shade apart and stops reading as raised at all.Each button owns its own press state. Two buttons on one control are two targets, so pressing the plus must not light the minus — which is why the recipe has no
bgPressedand the press is the sharedPressableFeedbacktreatment, theCloseButton's arrangement. Each goes flat and stops taking presses when the value has nowhere left to go, asked of the result rather than of the bound.A bin at the floor is
childrenand a ternary, not a prop: the condition is a basket row's rule rather than a stepper's, and a caller's ownonPressreplaces the step rather than running beside it — removing the row is not also decrementing it.NumberStepper.Valuetakes a function child for a unit or a plural, and writes an em dash rather than a zero while nothing has been pressed. -
da4bc8a: The
defaultvariant reads as grey rather than as near-whiteLight
defaultwas zinc-100 on a white background — a fill faint enough to be mistaken for no fill at all, where dark's zinc-800 sits clearly off its own background. One step to zinc-200 balances the two modes instead of shifting one.The derived layer follows from the single source:
defaultPressed,defaultSoftanddefaultSoftPressedmove with it, sotertiaryandghostkeep a pressed state that matches the new grey. Both packages regenerate theirtokens.gen.tsfrom that source. -
5f91549:
RowandColumn— the two axes of a layoutEach contributes one declaration,
flexDirection, and nothing else.gap,alignItems,justifyContentandpaddingareViewStylekeys that R14 already exposes as props on every node, so these two add no vocabulary of their own — which is the change from the legacy components, wheremainAxisAlignment,crossAxisAlignment,mainAxisSize,directionandreversedwere words to learn for what React Native already says.flexDirectionis the one style prop they do not expose: it is their identity, and aRowthat could be told to lay out as a column would be aViewwith a longer name.Three entries of the legacy
view/lot are deliberately not ported, because R14 removed their reason to exist:Paddingispadding={16}on the node itself,Centeris two alignment props on the parent, andSpacerisjustifyContent="space-between". Each added a view node to say what a style prop already says. -
c378a99: The
overlayshadow gets lighterIt was a 24-point blur eight points down, at 16 of Android's elevation. Android draws elevation on its own curve and draws it strongly, so a panel that read as lifted on iOS read as detached on Android — a dark halo about as wide as the gap between the panel and the field it came out of.
Half the elevation, two thirds of the blur, half the offset: still "above the page", without the panel looking cut out of it.
before after offset 0, 80, 4blur 2416elevation 168opacity .14/.6.10/.45It is the token rather than the component because a recipe names tokens and computes nothing — and because
Dialog,BottomSheet,PopoverandMenuare all going to read this one.Selectis its only consumer today, so nothing else moves yet. -
824d5b6: Declare
semverwhere it is used.tooling/pack-checkchecks, against the packed manifests, that@xaui/nativecan only ever appear once in a consumer's resolution tree: it is a peer and never a dependency, neither package carries a runtime dependency, noworkspace:protocol survives packing, and every peer range admits the version actually shipped. -
b8ef93d:
Pager— the indicator is one colour at two opacitiesThe dots were a pair of tokens: the current one from the variant, the rest from the neutral
defaultfill, which is whatCarouseldoes. A pair has to be chosen to contrast with itself, and it cannot be — measured in the DOM,tertiaryputsurfaceagainstdefault, which is#ffffffon#e4e4e7in 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.Every dot now takes the variant's one colour, the current one at full strength and the rest at
DOT_REST_OPACITY(0.3, exported). That cannot collapse whatever the variant, whichever the colour mode, and for any rawcolora caller invents — and it is what iOS's own page control does. 30% rather than 50%, because 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.Three consequences:
secondaryis nowforegroundrather thandefaultForeground— the page's own ink, for an indicator that has to read as chrome.- A pager over a photograph is
color="#ffffff"rather thantertiary: a photograph is a photograph in both colour modes, andsurfaceflips with the theme. The doc saidtertiarywas the answer; it was not. PagerDotInkand the context'sdotInkare gone, along with the flatten that built them — there is no pair for a worklet to interpolate between, so the recipe'sdotis a plain style and the travel is anopacity.
Also adds a full-screen
Pagerverification to the demo, which is the case a section inside aScrollViewcannot show, and the one that surfaced this. -
e6d8dfb: feat(widget): a card held in a soft frame
Widgetis the frame a figure, a table or a list is shown in: a quietdefaultSoftground with the header and footer sitting straight on it, one raisedsurfacecard for the thing itself, and a footer line for when it was last updated. It has one look — novariant, no primary/secondary/tertiary.sizemoves the padding, the gaps and the corner;radiusmoves the frame's corner;isElevated(on by default) lifts the card off the frame.The card's corner is derived from the frame's — the outer radius less the padding between them — so the arcs nest instead of reading as a sticker laid on the frame.
Chart.Legendnow works outside a<Chart>, which is what a widget's header needs: the title and the legend sit above the card and the figure sits inside it. -
4eda62d:
Popover— Trigger · Overlay · Content · Title · Description · CloseA panel anchored to whatever opened it, and the component the
Selectwas written before.Four sides, where the
Selecthas two. 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, soplacementtakesstartandendas well.widthdefaults tocontent-fitrather thantriggerfor the same reason — matching the width of a word or an icon would give the panel no room at all.No
variant. A popover is the theme's floating surface: no emphasis to report, no intent to carry, so a variant would name a decision nobody makes.Four things move out of the
Selectand become shared§2 bis, at the second use rather than by anticipation:
moved to the placement arithmetic utils/placement.tsthe trigger's measurement hooks/use-anchor-ref.tsthe measuring pass and the origin hooks/use-anchored-position.tsthe entrance and exit keyframes system/anchored/The arithmetic gained the two horizontal sides on the way, which is a real generalisation rather than a rename: on a vertical side the room bounds the panel's height, on a horizontal one it bounds its width and the height is bounded by the screen instead. A panel beside its trigger can be as tall as the window allows. Seven more tests cover it, on top of the twelve the vertical sides already had.
Two of the four exist because of bugs rather than tidiness, and both would have been rewritten wrong in
Menu,SubMenuandTooltip. The trigger measures again on every open, becauseonLayoutnever fires on scroll and a trigger inside aScrollViewotherwise 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 thePortalHost.The
Select's chevron spring moves tosystem/anchoredtoo, where theAccordionalready reads it.One bug the
Selectwas hidingThe measuring pass laid the panel out at the anchor's width. That is right for
width: 'trigger'— the content then wraps during the measurement exactly as it will afterwards, so the measured height is the real one — and it is exactly wrong forcontent-fit, which is the question "how wide does this want to be" asked while imposing an answer.Against a small trigger it measured a paragraph as a column one character wide, and held the panel at that width forever. The
Selectnever showed it, because its default width is the trigger's anyway.content-fitnow measures unconstrained, bounded by two things in this order: the component's own measure, and the screen.The measure is what stops "as wide as its content wants" from meaning the width of the screen — a paragraph always wants more, so a panel bounded only by the edges is a full-width panel the moment it holds a sentence, and a popover is an aside rather than a sheet. 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 that it stops being an aside and starts being a sheet with a tail. It is where the reference implementation's own panels land too, measured off their placement demos. A multiple of the type rather than a number of points, so a theme that scales its type scales the panel with it.
Both axes are clamped, not only the cross one
The side decides where the panel wants to go; the insets decide where it is allowed to be. The main axis was in the first half and not the second, so a panel beside a trigger with no room for it went off the screen entirely —
startandendwere unusable and nothing said so until one was opened.The panel may now overlap its own trigger. That is the right trade, and the one the reference implementation's
useRelativePositionmakes too: a panel covering the button that opened it is legible, and a panel past the edge of the screen is not.widthgains'full'for the case the measure exists to refuse — the screen less its insets, said out loud. Nothing else in the union can say it: a number is a guess at the screen's width, andcontent-fitdeclines by design. -
06d5069: Add
PortalandPortalHostto@xaui/native/system.Portalrenders its children into the nearestPortalHostinstead of where it sits, which is whatDialog,Sheet,DrawerandSnackbarwill be built on — an overlay has to escape the clipping and stacking of whatever container held the trigger. Publishing happens in a layout effect, so the content lands in the same commit as the trigger's and an overlay never shows a frame late.Outside a host the context is
nullandPortalrenders nothing rather than throwing: an app that forgotPortalHostshould lose its overlays, not crash on its first dialog. -
cd06df1: Fix the press scale, which lurched on wide controls, and align the touch feedback with The reference implementation's values.
The scale was a flat
0.975for every control. What the eye reads is the displacement, not the ratio: that same ratio moves a 360pt row nine points and a 96pt chip two. It is now0.985adjusted by a width coefficient, so the movement stays roughly constant in points whatever the control's width — the reference width is 300pt, andpressScaleForcarries the arithmetic with a test that asserts a chip and a full-width row travel the same distance. The curve is 300ms eased out, in both directions, instead of 100ms in and 150ms out.The wash goes to
0.1over 200ms. The ripple is Material'sInkRipplerather than an approximation of it: full ink in 75ms held while the circle keeps growing, a circle starting at 30% of its target instead of at a point, a target radius of half the diagonal, and a centre travelling from the finger to the middle of the control. The expansion runs a second while the finger is down and finishes in 225ms once it lifts, so the wave catches up rather than being cut.The ripple now draws, and the waves belong to the root. It never drew, and the first fix was wrong: the handlers went onto the overlay's own
View, which only hears touches that land on it. The overlay is a sibling of the component's children, not their parent, so a ripple worked on a button's padding and did nothing on its label — a bug that looks like a rendering problem. Touches bubble to thePressable, so that is where the handlers live; the root drives the two waves and publishes them, and the overlay only draws them.feedbackVariantis gone, and overlays are composed. The prop named a cross-product in a string —scale,highlight,ripple,scale-highlight,scale-ripple,none— which could name five of the six combinations it had and none of the ones a third overlay would add. A wash and a wave together, which is what Material actually does, was unreachable.The root scales, and anything laid over it is a part that wraps what it sits under:
<PressableFeedback isPressed={isPressed} style={styles.root}> <PressableFeedback.Ripple> <Label /> </PressableFeedback.Ripple> </PressableFeedback>Wrapping costs nothing, which is the part worth knowing: the children are not boxed. They come back as siblings of the wave's layer in a fragment, which has no presence in the host tree, so the root's
flexDirection,gapandalignItemsstill reach them directly and the rendered tree is identical to writing the overlay as a bare sibling. A real wrappingViewwould have been the trap — the root's layout would apply to the wrapper, the primitive would need to be handed the row'sgapto give it back, and it would add the view depth §8 removed.Written bare,
<PressableFeedback.Ripple />is that sibling and order does not matter: the root pulls its bare overlays out and paints them under everything else, so one written after the label does not end up on top of it — a 10% wash over text is subtle enough to ship by accident. A wrapping overlay is left where it is, since it already contains its content.markOverlayis exported, so a third party's own overlay part gets the same treatment.This is also the only shape that survives
asChild, and that was a real hole: the caller's element is the pressable there, so the primitive has no sibling to inject and mounted no overlay at all. AnasChildcontrol could not have one. Now the caller renders it.ButtondropsfeedbackVariantrather than renaming it. It has one treatment and always did: the recipe'spressedstate paints the variant's own pressed colour, so a wash on top would darken the control twice. It scales, and mounts nothing.The ink and the corners are resolved, not configured. The root flattens its own
styleonce and publishes both:backgroundColordecides the contrasting ink, and the radius keys decide the shape an overlay clips itself to. A purple fill gets light ink, a pale surface gets dark ink, and a translucent…Softtoken or no background at all falls back to the theme'sforeground— honest, because the control is showing what is behind it. The perf harness caught thatcontrastOnthrows on thergba()those soft tokens carry, which would have crashed every soft variant on first press.Carrying the clip on the overlay rather than on the root fixes a second thing: the root no longer sets
overflow: 'hidden', so a child that legitimately overflows — a badge on a button's corner — is no longer cut by a decision about the press.inkFor,radiusFromandpartitionOverlaysare pure and tested, as arepressScaleFor,rippleRadiusFor,resolveAnimationandresolveSlotAnimation— thirty-four assertions where the docs previously claimed a test that did not exist. Three carry a decision rather than an implementation: every control travels the same distance in points whatever its width, a translucent background falls back to the foreground instead of throwing, and a bare overlay written last comes back first while a wrapping one stays put. -
6cc7b49: Fix
asChildonPressableFeedback, which silently dropped every pressable prop.Under
asChildthe root renders aSlot, and aSlotmerges its props into its single child. That child was the feedback context provider, so the ref, the style, the press handlers anddisabledall landed on a provider that ignores them: the caller's element stopped reacting to touch entirely, with no error to say so. The provider now sits above the root, and the caller's element receives the props it was always meant to.The caller's element is the pressable under
asChild, so there is no sibling for the primitive to inject an overlay as. The context is published above the root, which is what lets<PressableFeedback.Highlight />work among the caller's own children — the only place an overlay can go here. -
c4d26ad: Add
PressableFeedbackto@xaui/native/system: the touch feedback every pressable component shares, instead of an animation file per component.It renders the pressable root and is controlled — the component above owns
isPressed, because its recipe resolves on that value and needs it before rendering. The root scales under the finger; anything laid over it is composed rather than named by a prop, through thePressableFeedback.HighlightandPressableFeedback.Rippleparts.asChildgoes through this component rather than around it: a root swapping it for a bareSlotwould render the child with no touch feedback at all.isDisabledreplaces React Native'sdisabled(R8), and each overlay takes its ownanimation—false, or adurationandopacity— over the blanket one on the root.animationon the root acceptsfalse,'disabled','disable-all'or an object switching sub-animations off one at a time. Turning animations off renders a different component rather than the same one with a branch inside, so no Reanimated hook is reached and no worklet is mounted.'disable-all'reaches descendants through context, so a long list disables every row's worklets with one prop.Also types
XAUITheme['fontWeights']as React Native's ownfontWeightinstead ofstring, which does not assign to it — every component readingt.fontWeights.mediumwould otherwise have needed a cast. -
18b3fd8: A pressed fill now moves one way: towards the ink of the mode.
accentPressed,successPressed,warningPressedanddangerPressedmix towardsforegroundinstead of the variant's own text colour. That text is picked for contrast, so its lightness followed the fill's and took the direction with it:#9333eacarries near-white text and lightened under the finger in light mode, while#c084fccarries dark text and darkened in dark mode. Same control, opposite gesture, and nobody had decided it.Now
#9333ea → #8533d3in light and#c084fc → #c691fdin dark — darker in light, lighter in dark — and the label's contrast rises in both modes instead of falling in one. The neutral fills already worked this way, sincedefaultForegroundandsurfaceForegroundare the mode's ink; only the four saturated intents ever flipped.deriveTintfollows the same rule, so a rawcolorbehaves like a token under the finger as much as it does at rest.Visible on every filled control, which today means the
Button. -
5c63340: feat(progress):
ProgressBarandProgressCircle— how far along something isThe
Steppershipped announcingprogressbarto a screen reader with no visual progress component anywhere beside it. These are the two, and they are two rather than one with ashapeprop because they share no geometry at all: a bar is aViewthat grows, and a ring is an SVG path whose dash offset moves. What they do share — the five variants, the clamped range,formatOptions, the 240ms — they share to the number.There is no
isIndeterminateon either. An unknown duration is aSpinner. That is the split the legacyIndicatorwas two components pretending to be one, and a bar that runs a loop across itself is a spinner drawn as a line.The bar's 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
radiusrounds both — an overlay would have needed a corner of its own and would have got it wrong at 100%. Itssizeis the rail's thickness and never its width, for theButton's reason.The circle's
radiusis a number, and it is the one place in this library where the word means what it means in geometry: a circle has no corner to round. It is raw, so it sits outside the style cache and wins oversizethe way a rawcolorwins over a variant's token — R6 keeps the ladder a vocabulary, and the escape hatch gets its own name. So doesstrokeWidth, and both are clamped: a stroke thicker than the ring is wide draws a path with a negative radius, which renders nothing on one platform with no error anywhere.The arc 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 sweeps, and it moves as an animated prop rather than an animated style because
strokeDashoffsetis an SVG attribute. The turn to twelve o'clock is on the wrapper:Circle's ownoriginX/originY/rotationemit an invalid DOM property on web.ProgressCircle.Indicatoris the first file in the library to importreact-native-svg, which stays an optional peer — the component is its own subpath export, so a project that never renders a ring never pays for it.Five variants, not ten.
tertiaryandghostare gone because a fill with no fill is not a progress bar, and the*-softpairs because the rail already is the soft half of every one of them. The rail is the same neutral under all five: it is the room left to go, and that is not success, warning or danger.utils/progress.tsis new and tested: the clamp, and the formatting. Which numberformatOptionsformats follows the style — the fraction for a percentage, the value for anything else — because formatting the fraction as euros reports a 1 250 € goal as 0,63 €.Intlmissing from a Hermes build without ICU falls back to a plain number rather than throwing. -
38584ee: feat(range-calendar, date-range-picker): a month that takes two days
RangeCalendaris aCalendar— the same root, and five of its seven slots re-exported rather than wrapped. Only the day cell differs, and only by having a band behind it, which is possible becauseCalendar.Gridtakes a function child.Three presses and not two: a range already chosen starts a new one, a day before the start becomes the start rather than a backwards end, and a one-day range is allowed.
DateRangePickerputs that month behind aSelect's trigger, in a sheet that closes on the second end only — a period is two decisions. -
88c692a: Publish to npm again, under the
alphadist-tag.Both packages were
privatewhile the v1 rewrite started from an emptysrc/. They are publishable again, but the repo is now in changesets pre mode with the tagalpha, sochangeset publishships them asalphaand leaveslatestwhere it is —@xaui/native@0.2.8and@xaui/hybrid@0.0.14, the last releases that actually carry components. Installing either package without a tag keeps returning those.pnpm add @xaui/native@alphais the opt-in. At this point it exports the theme layer only (createTheme,XAUIProvider, the token and colour utilities) — the components land from P2 on, one at a time, which is exactly what the tag announces. -
9763df7: Add ColorPicker at
@xaui/native/color-picker: aDummyFieldthat opens aDialogover the Tailwind palette, or that palette on its own as a grid. Two layouts —ramps, one named row per hue, andmosaic, every colour touching in one block with no labels. ShipsTAILWIND_PALETTE— the seventeen hues plus Zinc, at eight steps each — withGroupandSwatchto compose a palette of your own. -
8396052: Rename the compound row container from List to ListBox and reduce the default
primaryelevation so it sits closer to the page than a card. -
e0b3779: feat(empty-state): what is on the screen when there is nothing on the screen
EmptyStateis a header — a mark, a title, a sentence — and an optional row of actions, as two roots rather than one column: the gap inside the block is not the gap above the buttons, and two gaps need two roots (R4).plaindraws nothing and is the default, because most empty states fill a screen and a screen already has a ground.outlinedis the one that is not a fill: a dashed edge round the space the content would occupy, which is what a drop target wants. -
d2ce35e: feat(carousel): a series of slides, and the controls to move between them
Carouselin the v1 shape: the slides are children rather than adataarray and arenderItem, and every control — the arrows, the dots, the counter, the thumbnails — is a slot rather than ashowXprop.A slide's width comes from the measured track through
carouselMetrics, soitemsPerViewandpeekdivide it rather than a number of points that is wrong on the next screen size. The indicator follows the drag frame by frame on the UI thread, and the settled index is derived from the same offset — which is also what makes it work under a wheel or a trackpad.An arrow, a dot or an autoplay tick moves the track on a hand-run ease-out tween (~420ms, fast off the press and braking onto the slide) rather than
scrollTo({ animated: true }), whose curve is the platform's and close to linear. -
8009954:
RadialChart— several quantities, each as far round its own ring as it has gotP5.34h, net new, no legacy equivalent.
@xaui/native/radial-chart, and the family's sixth figure.A ring is not a slice. The
PieChartsplits 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 — which is why this is a component and not aPieChartprop.The first row is the outermost ring, and the palette walks the rows in that order.
Each ring has its own target.
maxKeynames the column that holds it,maxValueis one target for all of them, and with neither the largest value in the data becomes the top — so the biggest ring closes and the rest are read against it, theRadarChart's rule for theRadarChart's reason.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. Every track is drawn before every ring, so a 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.
radialRingsclamps the gap to half the room and divides what is left, with a test for each case — what gives is the thickness, because the alternative is a chart that silently drops its innermost rings.The arc is a dash offset, not a path rebuilt per value, which is what lets a ring be one stroke with one rounded cap at each end; the quarter turn to twelve o'clock is on the canvas's wrapper,
ProgressCircle's arrangement, becauseCircle's ownrotationprop emits an invalid DOM property on web — and because it leaves the middle upright. The middle itself ischildrenin aViewlaid over the canvas, thePieChart's arrangement.progressFractiondoes the value-to-arc conversion, its third caller after the two progress components. -
8b2c0e5: feat(radio):
Radio.Group— the set an option belongs toRadioshipped without one, which meant the one thing a radio is for — exclusive selection — was the caller'suseStateand theirmap. This is the context it was written to read, not a second radio.It is
Radio.Group, not aRadioGroupimport. The set publishes the values an option already reads and nothing else; a second module to make three radios exclusive would be a seam with nothing behind it.RadioGroupis exported as an alias for a call site that reads better naming it.Membership is a
value, not a nesting. The group holds the chosen one, each option compares the one it stands for, and nothing walks the children — so an option inside aCard, aList.Itemor aFragmentis in the set exactly as much as a direct child is. That is also what keeps a standalone radio over its ownisSelectedworking unchanged inside a group: an option with novalueis not in the set at all.variant,size,radiusandcolorare handed down as 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.isDisabledandisInvalidare 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.The group lays its options out, which is R4 and the reason it has a recipe at all — the gap follows
size, andorientation="horizontal"wraps rather than overflowing off a narrow screen. It paints nothing, because an option resolves its own colours and a group that painted would be painting over the row that disagreed with it.isSelectedstill outranks the set, so one option in a group can be driven by something the group knows nothing about, and both callbacks fire on a press: the option'sonSelectedChangeand the set'sonValueChange. Pressing the chosen option fires neither — a press selects and never clears, one level up from where theRadioalready said so.useRadioGroup()is exported (R10) for an option of your own that is in the set without being aRadio.accessibilityRole="radiogroup"moves onto the group, where the wrapper in the old three-line recipe used to carry it. -
b44037c: feat(radio): the v1
Radio— theCheckboxin a circle, with one rule changedThe ninth entry of the core. Same anatomy, same three levels on the same
field*tokens, same four boxes so a radio and a checkbox in one form line up — and a press selects, it never clears. A set of options has no "none of these" unless one of them says so, soonSelectedChangefires withtrueonly, and pressing the chosen option fires nothing at all.There is no group:
RadioGroupis a P5 component with a context of its own, not a prop this one is missing. A set is auseStateand amapoverisSelected={value === option}, inside aViewwithaccessibilityRole="radiogroup"— three lines the group component will replace rather than undo. The legacyRadioGroupand its shared props are named in the migration table, so nobody discovers the gap at merge time.SelectionFillmoves intosystem/: the fill that fades and grows in with the mark riding on it was theCheckbox's, and this is its second use — §2 bis says extract there. TheCheckbox's indicator now renders it too, which is thirty lines it no longer owns.Radio.IndicatorThumbhas no counterpart here: the dot is the indicator's default child, replaced by writing children, which is the same escape hatch with one component fewer.No
xs, matching theCheckbox. That circle was 16 points across with a 7pt dot, and a target that small is read rather than aimed at — the touch target is the row anyway, so shrinking the circle buys nothing a caller can press. The two components pair in the same form, so they offer the same three sizes or a caller finds the difference the hard way. -
09cda9d: Add the style engine, on the new
@xaui/native/systemsubpath.createRecipedeclares a component's style once and resolves it in two passes. The cached pass is keyed by finite tokens alone — theme, mode, variant, the axes, the active states — soStyleSheet.createruns once per combination for the app's lifetime and every slot reads a stable reference, which is what letsReact.memowork and keeps a press from allocating. Thecolorprop takes arbitrary values, so it stays out of the key and gets a second, uncached pass: the cache grows with the number of token combinations, not with the palette an app invents.A variant names tokens and a single
paintfunction says where they land, so the tint pass reuses it andcolorlands wherever the variant put its tokens — a background forprimary, a label forghost, a border fortertiary— with nothing further to declare.theme/derive-tint.tsexpands one raw tint into the six slices a variant consumes, using the same OKLab formulas as the derived colour layer, memoized per tint and mode. -
40b48e0: Add
Button— the first v1 component, on@xaui/native/button.<Button onPress={submit}>Envoyer</Button> <Button variant="danger" size="lg"> <Button.Icon as={TrashIcon} /> <Button.Label>Supprimer</Button.Label> </Button>Seven variants naming tokens and computing nothing — one emphasis ladder from the full accent down to nothing, plus
dangerand its soft level — four sizes driving height and never width,coloras one raw tint that lands where the variant put its tokens,isLoadinginserting a spinner when none is composed, andasChildhanding the press to someone else's element. The view depth is one —PressableFeedback > (Text | Icon)— and a press allocates no style: every combination of tokens is resolved once and cached for the lifetime of the app.Two fixes the component needed on the way:
- The build emitted classic
React.createElementagainst a binding the sources never import, so every component in the published package would have thrown on first render. esbuild now uses the automatic JSX runtime. - Every animated hook carries an explicit dependency array. Reanimated's Babel plugin
infers one, but it runs in the consumer's build and does not reach a published
diston web, where the hook throws instead of animating.
usePressStatenow acceptsnullhandlers, which is howPressablePropstypes them. - The build emitted classic
-
4c65aa2: Reword the third-party library attribution in doc comments and component docs
Every design note that named the upstream library it was measured against now says "the reference implementation" instead. No API, token, value or behaviour changes — only the prose in JSDoc, component
.mdpages and generated docs. -
5b1f8db:
Scaffold— Root · StatusBar · NavigatorP5.47, over
useAppearance(P0.7b). The hook answered what an app's chrome needs from the theme; this is that answer wired — the ground under every screen, the status bar over it, and the five options the app's navigator is dressed with.It depends on no navigator, and on Expo least of all.
Scaffold.Navigatortakes the app's own navigator as its child and clones it with the theme'sscreenOptionsmerged in. Those keys are React Navigation's spelling, declared structurally here asScaffoldScreenOptions, so Expo Router, a native stack, a drawer or a set of tabs are all dressed by the same component and@xaui/nativeimports none of them — not a dependency, not an optional peer, not a type import. Routing stays entirely the app's: nothing writes a route, wraps a screen or touches what the navigator was configured with.That is also why there is no
asChildon the slot. 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.mergeScreenOptionsis the one mergemergePropscannot do. That helper gives the child the whole value of a key it declares — right for astyleor a handler, wrong here: an app writingscreenOptions={{ headerRight }}means "add a button", not "drop the theme's header". So the theme's options go under the navigator's own key by key, the three style keys are flattened rather than replaced (aheaderStyle={{ height: 96 }}keeps its ground), and the function form stays a function, because React Navigation calls it per route. It is the component's only pure function, and the only thing in it with a test.The variant is a ladder, not four emphases — how much the header separates from the page.
primaryis the accent bar,secondarythe onesurfacedraws,tertiarythe page's own ground closed by a hairline, andghostthat ground with no edge at all, which is the default and what almost every app now wants.The page's ground is
backgroundunder every variant: a scaffold that repainted the page per variant would be a theme rather than a chrome. And the two flat variants name no fill in the recipe, which is what makes the tint follow the variant as it does everywhere else —<Scaffold color="#7c3aed">is a brand title on the page's ground, exactly as a tintedghostbutton paints its label, while a tintedsecondaryis a brand bar with contrasted ink.headerShadowVisibleis alwaysfalse: the variant owns the header's edge, so the navigator's own line never doubles the hairlinetertiarydraws.The demo's
_layout.tsxis now aScaffold— its localShellis deleted, and this app's status bar and header are what the component resolved.useAppearancestays the answer for a chrome the two slots do not reach. -
3dd1628:
Segment— a filter: one of a few options, chosen in place.It is not
Tabs, and that is the point. They wear the same clothes — a pill sliding under the chosen option inside a filled track, on the theme's ownsegmenttokens — and they do different jobs. A tab bar wraps content: its triggers name panels that live under it, and it saystablist/tabout loud. A segment names nothing; it holds a value the way a radio group does, and saysradiogroup/radio. 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.
Tabsmakes you write its indicator because a tab bar can belightand have none. A segment without its pill is not a segment, so the root draws it.Separators, off by default.
hasSeparatordraws a hairline between the options the pill is nowhere near, for a list long enough to need dividing. Both edges of the pill stay clear: a rule running into a raised surface reads as a crack in it, which is what iOS has done since the segmented control existed and why one does not look like a table. The rule belongs to the option on its trailing side, so an option decides alone from the rectangles every option already publishes — the root cannot know which child is which without reading its props, and that is introspection this library does not do.The tint reaches the word as well as the pill:
fgSelectedis a role rather than a token named in a state, so the tint pass follows it. Without that, a tinted segment would slide a coloured pill under a word that had stopped reading against it.The sliding itself moves to
hooks/use-sliding-indicator, shared with theTabs— §2 bis, promotion at the second use. Both are a filled shape following the chosen child along a row, and the two behaviours worth getting right are the same for either: nothing drawn before the first layout, and a first placement that jumps where every one after it springs. -
a3a2a13: feat(select): the field gets its label, its hint and its error
Selecthad no label. It is a field — it sits on the line where aTextFieldwould, and its recipe is theTextField's token for token — so a form using one had to label it from outside the component: a second column to keep in step, and a label a screen reader never associates with the control.isInvalidmoved the border and nothing else could say why.The root is now the column, the
Autocomplete's shape: aViewthat stacksSelect.Label, the trigger andSelect.Description/.Errorwith onegap, so JSX order is screen order. The column, the label and the help lines resolve throughtextFieldReciperather than a second table, so a select and a text field stacked in one form read as one control.isInvalidturns the label and the descriptiondanger; it never mountsSelect.Error, which stays yours to write.The trigger points at the two slots through
aria-labelledbyandaria-describedby, so what is announced is what the field asked for rather than only the row that happens to be chosen. Mounting the slots is all it takes; your own is still the last word.disablednow dims the column once instead of dimming the trigger a second time inside it. The root takesref,style,asChildand R14's style props for that column; the control keeps its own onSelect.Trigger.Breaking, on the
betaline:Select.Labelis now the field's label. The heading over a run of rows inside the panel isSelect.GroupLabel— the two were one name, and the field's label is the one callers reach for. Rename the slot insideSelect.Content:<Select.Content> - <Select.Label>Langues</Select.Label> + <Select.GroupLabel>Langues</Select.GroupLabel>The recipe slot moved with it,
labeltogroupLabel, andSelectLabelPropsnow types the field label — the panel heading isSelectGroupLabelProps. The root also renders a node where it rendered none, so aSelectlaid out as a flex child is now that column rather than its trigger. -
ade75b6:
Select— Trigger · Value · Indicator · Overlay · Content · Label · ItemA field that opens a list, and the first component in the library to use the
Portal. Its trigger is theTextField's twin — the samefield*tokens, the same four levels, the same heights — so a select and a text input in one form read as one control rather than as two libraries meeting.The visual values and the motion are the reference implementation's, not the legacy component's. The chevron turns 0 to −180° on their spring (damping 140, stiffness 1000, mass 4): heavily damped against a very high stiffness, so it arrives in a fifth of a second without overshooting, because an oscillating chevron reads as a bug rather than as motion. The panel grows out of the trigger at 200 ms from
scale: 0.95, offset eight points towards it, and leaves in 150 ms — a dismissal as long as the opening feels like the control is arguing.The root renders no node, which is where this component departs from every other one. The trigger is the control, so
ref,style,testID, the a11y props and R14's style props are all onSelect.Trigger. A wrapper view would have existed only to receive props the field already takes.XAUIProvidernow mounts aPortalHost. The provider README always said the host belonged there "later", and later is the first component that opens an overlay. It is not left to the app because forgetting it is silent:Portalrenders nothing outside a host, so a select would open onto an empty screen with no error to read.hasPortalHost={false}turns it off for an app that needs the host under a gesture root or inside its own navigation container.The panel measures itself invisibly for one frame before it places itself. That frame is what
avoidCollisionscosts: without a measured height there is nothing to compare, and a list too tall for the room below would open downwards off the screen. The arithmetic is a pure function with a test — placement is the one part of this component that is maths rather than rendering.sizeissm,mdorlg— noxs. A trigger that small has to hold a value, a chevron and the gap between them, and at that height the value gets nothing. TheTextFieldkeeps itsxsbecause a field only has to hold text.The panel's corner is
2xl, not3xl. The reference implementation's is their--radius-3xlon a base of 8, which is 24 points; our base is 12, so the same 24 is2xl. Reading their key rather than their number put a 36-point corner on it and made it read as a pill.Two narrowings against the reference implementation, both deliberate.
placementistoporbottomonly: a list as wide as its own field hanging off the side of it reads as a menu, andstartandendbelong toPopover. And there is nopresentationprop — the bottom-sheet and dialog presentations needBottomSheetandDialog, which do not exist yet.selectionModedoes not come across from the legacy component. A select that returns several values is a different control with a different affordance; calling both by one name is what made the legacy props list as long as it is. -
d8783d7: Add the shared hooks:
useControllableState,usePressState,useMergedRefandusePrevious.useControllableStategives a component one state that works whether the caller drives it or not, so there are never two code paths for the same value. Its setter keeps its identity across renders and reads the current value from a ref, which is what lets a handler built on it be passed to a memoized child. Switching between the two modes mid-life warns in development — it is always a bug, and invisible without it.usePressStateis the press state a root owns, with handlers that compose the caller's rather than replacing them and keep their identity across renders. Every pressable component needs those three properties and gets one of them wrong on its own.useMergedRefmemoizesmergeRefson the refs it was given, so React does not detach and reattach every one of them — and pay for a node measurement — on each render. -
228ea85: feat(flip-card): a card with two faces, and a turn between them
FlipCardpaints nothing and has no recipe: what turns is two faces the caller supplied, and each is usually aCardwith its own variant and radius. The front decides how big the card is and the back fills it out of flow.The two faces stay a half turn apart at every moment, which with a hidden backface leaves exactly one of them drawn —
utils/flip.tsis that relationship, tested, including the case it exists to prevent. -
7e2b158: Add
useAppearanceandappearanceForto@xaui/native/theme: the resolved theme read as app chrome — the mode, the ink the system bars need (which is its opposite), and the ground and ink for a navigator's header. The library still depends on nothing but React Native; this is what an app hands toStatusBar,expo-status-baror React Navigation'sscreenOptionsinstead of re-deriving it. -
806fa8d:
Skeletondrops itsvariantIt shipped with 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 defaultthe old secondarythe page (light) 1.216 1.100 a defaultCard(light)1.269 1.119 a secondaryCard(light)1.075 1.036 the page (dark) 1.336 1.123 a defaultCard(dark)1.189 1.090 a secondaryCard(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 very surface that claim named. And on a
secondaryCardin dark mode both resolve to that surface's own#27272aand vanish, which is precisely the case the pair existed to cover:defaultandsurfaceSecondaryare the same colour there.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, the neutral fill the rest of the library uses for asecondaryButton, andcoloris the way past it: honest about being a raw value rather than a name that promises a system.The recipe keeps a single-entry
variantTokensall the same, becauseresolveTintmaps the roles a variant declares and that mapping is what letscolorland on the block. -
c66aca9: feat(skeleton): the v1
Skeleton— two fills, one pulse, sized by R14 aloneThe fourteenth entry of the core. One node and no slots: a placeholder is a rectangle, and there is nothing inside it to name. A paragraph of them is three of these in a
Column— composition doing what alines={3}prop would otherwise hard-code, including the last line being shorter, which is the only reason the block reads as a paragraph.There is no
size, and that is the design. Only the caller knows the shape of the thing that is missing, so R14'swidthandheightare the whole sizing API — full React Native names and values,width="60%"as readily aswidth={140}. Asizetoken here would be a scale of rectangles nobody's content happens to be.variantnarrows to the two backgrounds a placeholder is ever drawn on:default, the neutral fill, for a block on the page, andsecondary, that fill at half, for a block on a surface that already carries one — where the full fill reads as a hole. No status families and noprimary, because a skeleton reports nothing and a placeholder in the accent announces the brand where there is nothing yet to announce; notertiaryand noghost, because a skeleton with a border and no fill is an empty box.The reference implementation reaches the same grey from
mutedat 30% opacity. Naming the token instead is what lets a theme move the skeleton by movingdefault, rather than by discovering that a percentage of a text colour is where the placeholder grey came from.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 core cannot require. One animation, soanimationis a boolean rather than a name to choose between — the block breathes between full opacity and a half, a second each way.No
asChild(R12), and the reason ischildren: here it means the content the block stands in for, andasChildwould need it to mean the element to merge the block's styles into. Onechildrenwith two meanings, disambiguated by a second prop, is the kind of API this library exists not to ship.isLoading={false}renderschildrenand nothing around them, which is what makes the component a gate rather than a shape you mount and unmount around your own content.The demo gains the two shapes a placeholder is actually written as: a card — the skeleton inside a real
Card, so the padding, the radius and the gaps are the card's and only what fills them changes on load — and a list of four rows, where the rhythm is the point and the line widths differ so the rows do not read as a loading bar. Both toggle back to their loaded content on a press, which is the only way to see that nothing shifts.The list sits on a
defaultcard rather than asecondaryone, and that is worth knowing: in dark modedefaultandsurfaceSecondaryare the same#27272a, so adefaultskeleton on asecondarycard is invisible — and thesecondaryskeleton, being that fill at half, is worse. The variant ladder has no answer on that surface. -
6ae0c41:
SlideButton— slide-to-confirm, theSlider's gesture without the value (P5.35d)The button you drag rather than tap, for the action that must not happen by accident: the thumb runs a
Gesture.Panon the same optionalreact-native-gesture-handlerpeer theSliderreaches for, the pill measures its own width on layout, and the travel is inset by the thumb at each end — but there is nomin,maxorstep, because the only positions that mean anything are "not yet" and "done".onConfirmfires once when the thumb passesthreshold(0.9 by default); below it the thumb springs home. Uncontrolled it is a one-shot and stays confirmed; passisConfirmedto drive and re-arm it. Everything the finger does stays on the UI thread — the offset is a shared value the fill and the disc read — with a singlerunOnJShop on release.<SlideButton>Slide to confirm</SlideButton>composes the fill, label and thumb from a bare string; the slots —SlideButton.Fill,SlideButton.Label,SlideButton.Thumb,SlideButton.Icon— are there to drop the trail or put a mark in the disc. The ten flat variants,sizeon the height and the type only, and acolorthat lands on the pill but never on the surface-coloured disc. The built-in chevron is drawn from two borders and flips itself under RTL; a drag reaches a screen reader through anactivateaction rather than anonPress. -
747aef8:
Slider— Output · Track · Fill · ThumbTwo callbacks, and the difference matters.
onValueChangefires on every step the thumb crosses, mid-drag included, and is what a live preview reads.onValueCommitfires once, when the finger lifts — it is where a network call belongs, because the first can fire fifty times in a second. The legacy component had one of each under different names and nothing saying which was which.The snap counts steps from the minimum, not from zero. A range from 5 in steps of 10 stops at 5, 15, 25; rounding the value itself would give 10, 20, 30 and move every stop. The rounding precision reads the minimum as well as the step, which is what the tests caught: a range from
0.05in steps of0.1has two decimals of precision, and rounding to the step's alone turned its first stop into0.1.The travel is inset by half a thumb at each end, and the fill runs to the thumb's centre rather than to the raw proportion. Without both, the thumb hangs over the track's ends at the extremes and the fill runs out from under it.
A press anywhere on the track moves the thumb there — the half of a slider people forget, because 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.
react-native-gesture-handleris an optional peer of this package and this is the first component to need it. It is imported inslider-thumb.tsxand nowhere else, so only an app that reaches for@xaui/native/sliderpays for it.No
variantand noxs: a slider reports a quantity rather than an intent, and a rail four points thick is a line rather than a control.A rail with a knob on it, not a capsule with a core
The legacy proportions rather than the reference implementation's: 6 to 10 points of rail under a 16 to 24 point disc. The knob overhangs the rail by half their difference on each side, and the rail reserves that overhang as a margin — without it the knob spills into whatever sits above and below, and the layout has no idea the control is thicker than its rail.
The three pairs are off the spacing grid on purpose. A rail is not a gap between two things, and rounding 6 to
spacing(1.5)would put the sizes on a scale with no bearing on how thin a line can be and still be pressable.The geometry is a compound of
sizeandorientationrather than two axes. The two cannot be written apart — which side of the rail is its thickness, which is its length, how far to pull the knob back — and anorientationaxis settingheight: undefinedto undo asizeaxis's height is how this first shipped: declaration order is application order, the second axis won, and the rail had no thickness at all.Three steps of one colour
The rail is the theme's neutral, the reach is the colour at thirty-five percent, the knob is the colour at full. The eye lands on the knob, which is the value, rather than on the bar behind it, which is only how far the value has come.
Material's slider is the same relationship with the steps assigned differently: their inactive track is the soft one and their active track is full. Moving the soft step onto the reach is where this stops being theirs — a filled bar at full strength competes with the handle for the eye, and the handle is the part you can move.
The thirty-five percent is derived from the resolved role in the recipe rather than named in the theme. The soft family is a pair, fifteen and twenty, sized for a chip or a soft button; a bar three hundred points long needs more than either. Adding a third step would move every
*-softfamily in the library for one component's sake. Taking it off the role also means a rawcolorflows through untouched, and the reach and the knob can never drift apart because they come from the same place.Disabled drops the colour entirely rather than dimming it: a pale wash reads as an enabled slider seen through fog, a neutral one reads as switched off.
Ranges and vertical rails
value={[20, 60]}is two thumbs and a fill between them, and it reports a pair back — the shape the caller wrote is the shape they get. One<Slider.Thumb index>per end, written rather than conjured by the rail.The thumbs cannot cross. Each is bounded by its neighbour rather than by the range, so dragging the lower past the upper stops it dead instead of swapping the two: a swap loses the finger's grip mid-drag, and it ends up pushing the thumb it did not pick up. A press on the rail moves the nearest thumb, because moving the first every time would send half a range's presses over the other end.
orientation="vertical"counts from the bottom. A rail whose fill grew downwards would report a larger value the lower the knob sat, which is the opposite of what a vertical control means everywhere it appears — it reaches the gesture, the press and the fill's anchor, three places that each had to be inverted.Ten more tests on
withThumbAtandnearestThumb, including the non-crossing in both directions and the tie that always goes to the lower thumb. -
c0a6e44: Add the slot primitives to
@xaui/native/system:createSlotContext,childrenToString,Slot,mergePropsandmergeRefs.createSlotContext(name)returns a[Provider, useSlot]pair, so each compound names the hook it exports and a slot read outside its root throws an error naming both the hook and the component instead of failing three frames later onundefined.childrenToStringimplements the text auto-wrap once for the whole library. It stringifies the tree recursively rather than inspecting the first child, which is what makes<Button>{count} items</Button>— children[3, ' items']— resolve to'3 items'.Slotis theasChildrender branch:const Root = asChild ? Slot : Pressable. It merges throughmergeProps, which composes event handlers rather than replacing them, stacks styles with the child's on top, keeps aPressablestate-function style callable, and merges refs.asChildhas to be uniform from the first component — retrofitting it changes the ref signature of every core component at once. -
3a5fd0e: feat(mask-field): a value typed into a shape
MaskFieldis theTextFieldwith its box masked: the same root, the same variants and sizes, the same label, description and error slots, and only the field differs. There is one representation — the accepted characters, in order — andmaskInputis the only thing that turns them into text, which is what makes the field survive a paste, a punctuation keyboard and a backspace over a separator.maskis a preset or a pattern.'date','time','datetime'and'credit-card'carry their own rules — the date order and separator from the locale throughIntl, and a part clamped as it completes and never raised. A date that cannot exist stays out of the box the moment the month is known. Anything else is a pattern string:#a digit,Aa letter,*either, every other character a literal put back in as the parts fill.The value is the masked string.
convertis the one plug that turns it into a value of your own —parseMaskedDateandparseMaskedTimeare exported for thedateandtimeshapes, andMASK_FIELD_MASKSlists the presets.useOptionalFieldGroupjoinsuseFieldGroup, so a field can leave a decorator its room without requiring one — the shapeuseOptionalChartalready has. -
c01ade0: Add the v1 Snackbar notification API, including a vertical
Snackbar.Stackfor displaying several independently controlled messages without overlap. -
f8cc8d6: feat(spinner): the v1
Spinner— seven inks, two rings, no SVGThe fifteenth entry of the core, and the one
Button.Spinnerwas named after. 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.A variant here names an ink, which is the narrowing of §1 bis this component argues for. On a
Chip,fgmeans "the colour that reads on this variant's surface", soprimaryresolves toaccentForeground— white. A spinner has no surface, soprimaryisaccent,secondaryis the accent as it reads on the page,defaultisforeground,tertiaryismuted, and the three status families are there for the wait whose outcome is already named: deleting is adangerwait. Noghost, because a spinner with no ink is not a spinner, and no-softslices, because a soft slice is a fill softened.The reference implementation fades a single arc from opaque to 55%, which needs an SVG
linearGradientand thereforereact-native-svg— an optional peer, which a component in the fifteen-component core cannot require. Two circles of one ink at two opacities read as the same figure, cost two views, and pull in nothing. The track is what does the work: a rotating three-quarter ring on its own reads as broken rather than as busy.sizeis the diameter and the only measurement a circle has — 16, 20, 24, 32, the reference implementation's three steps plus the one our ladder adds between the first two. The stroke thickens once, atlg.The turn moves to
hooks/use-rotation.tson its second use, per §2 bis, andButton.Spinnerstops carrying its own copy — with one duration for the library, because two spinners on one screen at two speeds is a bug and one number is the only way to be sure of it. That slot stays its own component rather than becoming<Spinner size={…} color={…} />: everything it draws was already resolved by the button's recipe, and handing those two numbers to vocabulary props would be R6 in reverse.The demo's screen list becomes data in the same change — a dozen adjacent hand-written buttons in one JSX block is what made it conflict on every component branch.
-
804f32f:
Stepper— where you are in a sequence of steps.The value is the caller's, always. There is no
defaultValueand noonValueChange, because nothing inside a stepper can move it: a step is not a control, it is a report. The number comes from the form, the wizard or the route that actually knows, and an uncontrolled stepper would be a piece of state that could never change. It counts from one, sovalue={2}is "step 2 of 4" — the number you would say out loud rather than an index.The root numbers its children. An item declares no index and no key: JSX order is step order, so inserting a step in the middle renumbers the rest by being there. 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.Three statuses, and they are an order. Every step before the current one is completed and every step after it is upcoming. A completed step keeps its full contrast — it is a thing you did, not a thing greyed out — and what recedes is the road ahead. The line under the current step is still track: the stepper has not left that step yet.
Two orientations that differ by more than the axis.
verticalputs 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.horizontalcentres each indicator over its label and gives every step the same width, so the circles land at even intervals whatever the labels say.The connectors belong to the indicator rather than to the root, which is the opposite of the
Accordion's separators: 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. A horizontal step carries two halves, one either side, so its circle stays centred over its label — and the two ends of the rail are drawn transparent rather than dropped, or the first and last circles would slide off theirs.A step is not pressable, and that is
asChild's job rather than a prop. A stepper where a completed step takes you back is one composition away; one where tapping ahead skips a form's validation is not something this component should make easy.colorpaints the progress and not the track: the travelled line, the ring around the step you are on, the disc behind the ones you are past. The road ahead stays grey, because the untravelled track is written from the theme rather than named as a role.The tick a completed step draws moves to
utils/check-glyph, shared with theCheckbox: two borders of an empty box a quarter turn from where they look like one, so both work in a project that has installed no icon set. -
2838877: R14 reaches every component that renders a node, not only the
Button.PressableFeedbackand itsHighlight/Rippleoverlays,PortalHostandIconnow take the style keys of the node they render, the same way theButtonand its slots do. The primitive every pressable control in the library is built on cannot be the one place wherepadding={16}has to become an object again.<PressableFeedback padding={12} borderRadius={16}>…</PressableFeedback> <Icon source={logo} marginEnd={8} />Icon's reach thesourceform only, exactly as far as itsstylealready 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. That is the rule applied, not an exception to it — the rule says the node the component renders, and there is one in three.On
PressableFeedbackthey merge intostylebefore either branch sees it, so the ink and the corners an overlay reads off its surface include abackgroundColoror aborderRadiuswritten as a prop. -
51986e5: Style as props (R14) —
useStylePropsandsplitStylePropson@xaui/native/system, and theButtonon them.<Button padding={16} marginTop={8} width="100%">Envoyer</Button> <Button.Label fontSize={18} letterSpacing={1}>Envoyer</Button.Label>Full React Native names, and therefore full React Native values:
padding={16}is 16 points, exactly asstylewould be — a prop carrying the RN key's name while multiplying its value by a scale would be the trap you only catch by measuring on screen. The scale stays one word away,padding={t.spacing(4)}.The set is the node's style type minus the directional forms R13 bans, which are not exposed at all:
ViewStylePropson a root,TextStylePropson a text slot. A name the component already uses stays the component's —sizeis the control's scale,coloris R7's tint. They resolve outside the style cache, after the tint and beforestyle, which is still the last word.Button.Icondeliberately takes none: two ofIcon's three forms render no view of ours. -
4d1d3dd:
Surface— a ground for other things to sit onOne 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 the smallest thing here and the most reused.
It is not a
Card. A card has decided things for you — it is always lifted, it has a header and a footer, and its levels carry an emphasis. A surface has decided nothing.A ladder, not three emphases.
primarysits on the page,secondaryinside aprimary,tertiaryinside asecondary, and each names tokens the theme already states per mode — so a nest is legible in light and in dark without anyone choosing greys. Three is as deep as that reading survives; a fourth would be a shade nobody could place.tertiaryis an edge rather than a third grey. Below asecondarythere is no grey left that still reads as a level, so it takes the page's ownbackgroundand draws itself with aborder— which lands darker than its ground in light and lighter than it in dark, from the tokens alone. There is noghost: a surface with no ground is not a surface, and padding and a corner are already style props.Elevation is asked for rather than tied to the variant, and defaults to true for
primaryalone: a shadow under a ground that barely differs from the page reads as dirt rather than as height. Whether a ground is above the one under it is the layout's business — the samesecondaryis flat inside a card and lifted floating over a list.Its props list is six lines because everything else a surface could be is already a style prop. There is nothing here a prop had to be invented for.
Card,Popover,AccordionandDialogshould read it. None of them does yet: that is a refactor rather than a component, and it wants its own change so a regression in one of the four is not hidden inside a new file. -
8e586e2: feat(switch): the v1
Switch— two shapes, one flipThe tenth entry of the core, and the third of the toggles. The root is the row, so tapping the label flips the switch; R3 wraps a text child into the label and supplies the track and the knob.
variantis a geometry axis here, which no other component does:primaryrides the knob inside the track,secondarystands it over a thinner bar. They are the legacy component'sinsideandoverlap— the same two shapes and the same measurements — under the library's own two names, so the v1 API keeps one vocabulary instead of a third pair of words for this component alone. Both are the accent when they are on, which is why the whole table lives in eight compounds and the colours in onepaint.No
isInvalid. A switch applies its change the moment it is flipped, so there is no later moment at which it can be wrong — a checkbox states an intention a form submits, and that is the one that can be. A setting that cannot be turned on isisDisabled.The track's colour is crossed rather than swapped and the knob slides on the same 175ms, from one constant neither slot owns, so a flip reads as one movement. Both are values on the context rather than styles — a worklet needs a number and a string, not a style to flatten every frame — and the travel is
width − knob − 2 × inset, arithmetic the root does.coloris the colour the switch turns on to; the track at rest keeps its neutral, because a switch that is off is off in every brand.The knob moves with
translateXand the sign is flipped againstI18nManager.isRTL: R13 bans a directional inset, and a transform does not mirror on its own.Both animated hooks carry a
'worklet'directive and a dependency array, which the rest of the package already required and these two were missing. Without them the demo's/switchscreen threw outright on web — "useAnimatedStylewas used without a dependency array or Babel plugin" — while lint, type-check and the test suite all stayed green, because none of them renders anything.pressable-feedback.tsxstates the rule and the reason: the package ships as a builtdist, our CJS output calls the hook as a namespace member that the Babel plugin does not recognise, and the directive is whattooling/workletize/keys off.The dependency arrays are load-bearing beyond the crash.
distanceandcolorsare plain values captured in the closure, not shared values, so without them a switch whosesizeorcolorchanged would have kept animating to the old travel and the old ink.No
xs, matching theCheckboxand theRadio. That track was 40 by 24 with an 18pt knob — and unlike those two, a switch has no row to press: the track is the target. Belowsmit stops being comfortably hittable, and shrinking the one control whose whole surface is the touch target buys width nobody asked for.radiusmoves the knob with the track. It reached only the track, so aradius="sm"switch squared off its bar and kept a circular knob inside it — a control rounded by halves.radiusAxisbecomes variadic to say it, which is the second slot it has been asked for since theCard.The knob takes the same named corner rather than the track's less its padding. The nesting rule that would suggest otherwise reaches zero before the outer radius does — at
xsa 3pt track would hold a sharp-cornered knob — and two matched corners read better than one correct one. -
277a713:
Tabs— List · Trigger · Label · Indicator · ContentThe 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. Its 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.
Tabs.Indicatoris written by the caller, inside the list. Leaving it out is a legitimate bar, where the label's colour is the only thing saying which tab is chosen, and that is why it is a slot rather than something the list conjures.Three shapes, not three emphases.
primaryis the segmented control — a pill inside a filled track;secondaryis the underline;lightis neither, with no track and no rule and nothing but the chosen tab's label going to the accent. Different affordances rather than the same one louder, which is why the union is three rather than the usual four. All three read the same roles, so a tint lands on any of them through the same names — painting a pill, a rule or a word.lightnames nobgand nobgSelected, and that is how it has no track and no rule rather than an omission:paintresolves both to nothing, so the list stays transparent and the indicator, with no fill and no compound to give it a size, draws nothing even when a caller leaves<Tabs.Indicator />in place. Its chosen label goes to the accent rather than the foreground, because with nothing else moving the colour is the whole signal, and a tab merely darker than its neighbours is not chosen — it is just darker.A tab is named, not numbered. The legacy component took an
activeIndex, which breaks the moment a tab is inserted.A panel is mounted only while its tab is chosen. 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 the caller holds the state for, which is the same trade every router makes.
No
xs: a tab is a target before it is a label, and at that height there is nothing left of it.A scrollable list and
Tabs.Separatorare not here. Centring the chosen tab when the bar overflows means the indicator has to account for a scroll offset the triggers' own layout does not report, which is worth its own change. -
ef7332a:
TagGroup— List · Item · ItemLabel · ItemRemoveButtonIt is not a row of
Chips, and that answers a question this roadmap has carried sinceTagGroupwas first listed beside aChipthat already shipped.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 — which is also why the two do not share a recipe. A chip has ten variants because it reports an intent; a tag has two grounds because it reports nothing at all until it is selected.
Two grounds, not two emphases.
defaultis the theme's neutral fill,surfacethe card colour, and they swap so a tag never disappears into what is behind it: a group on a card wants one, a group on the page wants the other. A selected tag leaves both for the accent's soft slice, the only place this component uses colour.The cross renders nothing without an
onRemove. Removing a tag is the caller's 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 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.It reads
system/close-button, so the press state, the grown touch target, the missing-label warning and the drawn cross are the shared ones — six lines here, and the third component to read them afterChipandAlert.The selection rule is a pure function with ten tests, and it returns the list unchanged whenever a press changes nothing:
useControllableStatedrops a set to the value it already holds, soonSelectionChangenever fires for a change that did not happen.Both faces of a tag are resolved once on the root, so a group of forty costs what a group of two costs and no slot touches the recipe (R5).
-
7376ce5:
asChildreachedSlotas an array, so every pressable threwPressableFeedbackrendered{overlays}{content}— two expression children, which React hands to the root as an array. UnderasChildthat root is aSlot, which merges into a single element and threw instead, whether or not an overlay was composed: with none,partitionOverlaysreturnsoverlays: nulland[null, content]is an array all the same.<Button asChild>was unusable, and so was every other pressable.The root's children are now computed once, as a single node, by
feedbackChildren.asChildskips the partition entirely: the caller's element is the pressable, so an overlay written inside it belongs to it and hoisting would make it a sibling of the very element it was composed into. -
f38983f:
InputbecomesTextField,InputGroupbecomesFieldGroupBreaking, and deliberately taken now: the package is on the alpha line, so this costs a changeset rather than a major. Seven planned components are described in terms of this field —
NumberInput,PhoneNumberInput,SearchInput,DateInput,BottomSheetInput— and each one written before the rename would have been written against a name about to move.Inputwas never the right name here. The root is not the thing you type into: it is the column that holds a label, a field, a hint and an error, and keeps them in step.TextFieldsays that, and it leavesFieldfree to mean the one node that is actually aTextInput.TextInputwas the obvious candidate and is the one name to avoid. React Native exportsTextInputandTextInputProps, both imported by the field, the group's field and the text area. A publicTextFieldPropssitting beside React Native'sTextInputPropsis a name collision in every file that touches both, and a reader's coin flip in the ones that do not.FieldGrouprather thanTextFieldGroupfor the same reason the root droppedInput: the group decorates a field, and the field's own type is not its business.before after @xaui/native/input@xaui/native/text-field@xaui/native/input-group@xaui/native/field-groupInput,Input.FieldTextField,TextField.FieldInputGroup,InputGroup.IconFieldGroup,FieldGroup.IconuseInput,useInputGroupuseTextField,useFieldGroupInputProps,InputVariantTextFieldProps,TextFieldVariantinputRecipetextFieldRecipeThe slot names do not move.
TextField.Fieldstill stutters asTextFieldFieldinternally, and that was the trade taken: renaming the slot would have changed every call site that the rename otherwise leaves alone.InputOTPkeeps its name. It is not a text field with decoration, and nothing in it reads the field's context. -
ec31e18: Add
Fab.Discoveryat@xaui/native/fab: the coach mark that says what a FAB is for. The target is theFabrather than a copy of it — lifted into the portal at its own measured rectangle while the mark is up, so it draws above the disc, still presses, and never appears to move. The text is laid out to the chord of the disc at its own height rather than to its diameter. -
c20ac35: Rename the floating-action compounds from
Fab.MenuandFab.DiscoverytoFabMenuandFabDiscovery, and remove theListBox.Groupalias in favor ofListBoxGroup. -
bac69d0: Add SearchField at
@xaui/native/search-field: a TextField with a built-in magnifier, a clear button and a search key, in two fills — a flatprimaryand a softsecondary, neither carrying the field shadow. -
cce2d57:
Timeline— the row is built on one lineThe dot, the time and the title were each placed by their own arithmetic, so none of them met. Everything now hangs off a single number,
line— the middle of the title's first line, which is the height an entry reads at.The insets were written by hand and drifted.
insetwas a per-size constant (5 / 7 / 9) rather than the value it stands for, so the dot's centre landed at 13 against a line at 12 atmd, and at 17 against 14 atlg.timelineInset(line, height)derives it now, from the title's ownlineHeight, which means a theme that changeslineHeightskeeps the column together instead of pulling it apart.Timeline.Leadingwas never on the line at all. A cell in a row stretches, and a stretchedTextdraws at the top of it — so a time, set smaller than the title it labels, sat four points above both the dot and the words. It takes the same line as the marker now: an inset on astartentry,alignSelf: 'center'on acenterone. It also setsfontVariant: ['tabular-nums'], because right-aligning proportional figures still leaves11:06and10:43starting in different places, and times that each begin somewhere else read ragged however straight their right edge is.An end segment is now drawn, just not painted.
Timeline.Connectorreturnednullfor the last entry's lower half and a bareViewfor the first entry's upper half, and the marker's place in a rail is decided by what is above and below it — so oncenterthe first dot jumped to the top of its entry and the last one fell to the bottom, an entry's height of error. Only the colour goes now; the flex stays.Timeline.Railtakesmarker— how tall the marker it carries is, when it is not the dot. The upper half of the line is a height, and the only height a rail can work out on its own is the dot's, so a rail told nothing places a 28pt circled icon eight points below the title. The number is republished into the context rather than passed to the connectors, which are children the rail does not own (R1). It stays out of the recipe's selection, so it cannot reach the style cache.The rail's column is a
minWidthand the entry is a row with agap. A composed marker wider than the dot used to spill out of a fixed 24pt column and land on the words; the three columns had no gutter between them at all. The gutter cannot be padding inside the rail — the rail's own slack is what centres the line in it, so widening it moves the line rather than the text. -
541cbe7: The toast stack collapses, like the reference implementation's.
It was a flex column with a gap: every card fully visible, one under the next, so six toasts took six card heights down the screen. The reference implementation's is a pile — one card in front, the rest scaled down and pushed toward the edge behind it, only their shoulders showing.
Every card is now anchored to the same edge and its depth is entirely in its transform:
translateY: 10toward the edge andscale: 0.97per step, their values, read off theirtoast.animation.ts. A pile of eight costs the height of one. The ladder does not clamp — their interpolation clamps the front side only, so the fourth card is genuinely further back than the third rather than sitting on it and reading as one.limitbecomesmaxVisible, and it no longer discards. A card past it is transparent, keeps its timer and its place, and is promoted into view when the one in front leaves — so a burst of six shows all six instead of losing three. -
7557e46: The front toast can be thrown away with a swipe, like the reference implementation's.
Away from its edge — up on a top stack, down on a bottom one — past 50 points or 500 points a second, their thresholds, either alone being enough. Dragged the wrong way it resists rather than refuses: the whole screen's travel maps onto 40 points. The throw carries on at the speed the finger left it and the record goes a moment later, so a hard flick leaves faster than a soft one.
Only the front card. The ones behind show a seven-point shoulder, which is a target under any reasonable minimum, and dragging the second card out from under the first reads as a glitch rather than as a dismissal.
isSwipeable={false}on the host turns it off.Note that this dismisses one card and the pile empties a swipe at a time — the reference implementation's gesture calls
hide(id), not a clear-all, and their provider has no such thing.The gesture runs on
react-native-gesture-handler, already an optional peer, reached only through@xaui/native/toast. -
f8dfa9d:
Toast— Title · Description · Actions · Close, plusToastHostanduseToastThe card does not know it is in a queue, when it will leave, or what is stacked under it. The host owns all three, and that split is the whole design:
renderreturns anything at all and the queue never looks at it, soToastis the card this library ships rather than the card the host requires.Toast.Closestill knows which toast it belongs to without being told. The host provides the dismiss around each entry and the card folds it into the context its slots read, so a close button two levels down needs nothing passed to it.The variant paints the title, and nothing else. A red card sliding in from the edge of the screen reads as the app breaking; a red line of text reads as the thing you just did failing. The surface stays the theme's floating one whatever happened, which is also what lets two toasts of different kinds stack without the pile looking like a paint chart. It uses the soft foregrounds rather than the full colours, because a toast is read from the corner of the eye and
dangerat full strength is a shout where the soft one is a statement.It slides from the edge it will sit against, where every other overlay here scales in place. A dialog and a popover appear where they are, because they were asked for; a toast arrives, because something happened. Motion across the screen's edge is the difference.
Past
limitthe oldest goes: the newest is the one that just happened, and the reader is looking for it.useToastoutside aToastHostwarns and does nothing rather than throwing. A missing host is a setup mistake in the app shell, and a screen that crashes on its way to reporting that a save succeeded has turned a good outcome into a bad one.It closes P5.18 as well as P5.18b:
SnackbarandToastare the same object under two names, and the reference implementation calls ittoast. -
f181310: feat(toggle-button): add exclusive ToggleButton.Group selection
ToggleButton.Groupowns one selected value, with controlled and uncontrolled APIs. Its members opt in throughvalue, inherit the group appearance defaults, and remain usable at any nesting depth through context. -
91cf010: feat(toggle-button): add an independent two-state action
ToggleButtonfollows the button's fixed-height scale and dot-notation composition while owning or receiving a boolean selection. It exposes controlled and uncontrolled APIs, publishes selected, pressed and disabled state to render children and custom slots, and announces selection throughaccessibilityState.Three variants provide filled, softly filled and content-only treatments.
primarymoves from the neutral fill to the accent,secondaryuses soft fills in both states, andghoststays transparent while its content changes to the accent. A rawcolorfollows through the uncached tint pass.ToggleButton.LabelandToggleButton.Iconinherit the resolved selection colour, and icon-only controls keep the same missing-label warning asButton. -
7abe5b5: The build cleans
dist/before writing itclean: falsehad been set since the legacy era, with nothing saying why, and againstsplitting: trueit is a bug waiting for the entry list to change.Split output names its shared chunks by content hash. A build whose entries changed writes new chunk names and leaves the old ones behind, so
dist/becomes a mix of two builds: an entry from the first still importingchunk-ZF6KIHXH.js, which the second replaced with a different hash and never wrote.Metro's report of that is
Unable to resolve "@xaui/native/accordion"— it names the component and says nothing about chunks, which sends you looking at the export map, the subpath and the workspace link, all three of which are fine.It bites hardest across branches, because turbo restores a cached
dist/**over whatever is already there rather than in place of it. Switching from a branch that has a component to one that does not, or back, is enough. -
662fdfc:
warningmoves from theamberfamily toorangeThe dark
warningwasamber[400], a distinctly yellow 84° in OKLCh, which read as gold rather than as a caution — next to a greensuccessand a reddangerit looked like a third decorative colour instead of the middle of a status ladder.Swapping the family moves both modes the same way and narrows the gap between them: the two ramps sit 35° apart today at the steps we use, and 18° after. Light barely moves at all —
amber[700]andorange[700]are 11° apart and share a lightness, so the change there is a slight warming rather than a new colour, and the contrast againstwarningForegroundgoes up, 4.81 → 4.96. Dark moves further, because that is where the yellow was.Everything derived follows through
deriveColors:warningPressed,warningSoft,warningSoftForegroundandwarningSoftPressed.pnpm tokens:checkpasses on both modes.It reaches every component with a
warningvariant —Chip,Alert,Badge,Spinnerand the ones still in review — which is the point of the token layer: one line intooling/tokens/source.ts, no component touched. -
8763184: A WheelPicker reads at a glance again, and turns under a parent scroll:
The row at the middle is bold, on top of the band's colour — weight now says which row is chosen together with where it sits, and unfocused rows hold the body weight. The fade and lean away from the middle were doing that alone, and a still wheel next to a still list read as the same thing.
The columns set
nestedScrollEnabled, because a wheel most often sits inside a scroll of its own — on Android a verticalScrollViewunder a verticalScrollViewkeeps the gesture for itself unless the child asks, and a wheel that will not turn is not a wheel.Rows are a size step taller —
sm36,md40 — so the band reads as a target you aim at rather than a hairline, and a turning row has room to lean into.lgkeeps its 44.ghostandtertiaryrows nameaccentSoftForegroundrather thanforeground, andsecondary's band isdefaultSoft: under a tint, a bareforegroundresolves to the tint itself, which painted the row the colour of the band it sits on. -
7074a3b: feat(wheel-picker): a column of options you turn, and the one at the middle is the answer
P5.25b, and it comes before the three pickers that need it:
WheelDatePicker,WheelTimePickerandWheelDateTimePickerare all this component with a different set of columns and the arithmetic to fill them.The column has the value, not the wheel. A time is two columns and a date is three, so a wheel with a single value would be a wheel that can only ever be one of them.
The scroll is the control. There is no press to select: the row at the middle is the choice, so a column snaps to a row and reports whichever one it stopped at. That is what makes this a wheel rather than a short list, and why the rows are
Textnodes — a row you could tap would be a second way to choose that the band does not describe.It reports at rest, never while turning. One flick passes nine rows, and every one of them is a value some caller would have written to a form.
onScrollEndDragcovers a drag that stops without momentum andonMomentumScrollEndcovers the flick; both are needed, and neither fires for the other.The rows fade and lean away from the middle, read off the column's scroll offset on the UI thread through a shared value. That is not decoration: it is the whole of what says this is a drum with more of it out of sight rather than a list that happens to have stopped. A position crossing the bridge every frame would animate at the rate React re-renders rather than at the rate the finger moves.
The band is the root's, one shape across every column rather than one per column — two columns at different widths would show the seam between two bands — and it takes no touch, so it marks the middle without stopping the wheel under it.
visibleCountis forced odd, because the whole control is built on there being a middle row, and rounded up rather than down: a caller who asked for four wanted more than three. It is raw rather than a token, like theProgressCircle'sradius, so the wheel's height is applied after the cached recipe.No
loop. An endless drum is a list with no end, faked by rewriting the data around the finger and jumping the offset back when it drifts. That belongs to the caller's data, where the caller knows how many months there are; here it would be a component quietly renumbering its own children.Four levels and no intent — what the variant names is the band.
secondarynamesdefaultForegroundrather thanforeground, and the difference is only visible under a tint:resolveTintreads the role off the token's own name, a bareforegroundmaps to the tint itself, and a band painted the same colour as the row on it is a row you cannot read. That one was caught on the demo screen, which is what the demo screen is for.The first placement is
onContentSizeChangerather than the effect that follows an outside change:contentOffsetonly takes on iOS, so on Android and web the wheel would open showing its first row while reporting its fifth — wrong on two platforms out of three. -
76441cd: Ship workletized code, or every animation is a hard crash.
The Reanimated Babel plugin turns a function into a worklet — a serialized body plus its captured closure — and Reanimated aborts the process when it is handed a plain function to run on the UI runtime:
Abort trap: 6insideWorkletRuntime::runSync, with no JavaScript error to read. In an app that transformation happens in the consumer's Metro build; over a publishednode_modulesit does not. The libraries that get this right ship their source, so Babel sees the original call sites; we ship a compileddist, so the same pass now runs in our own build (tooling/workletize/).Every animated hook also carries an explicit
'worklet'directive. It is the load-bearing half: the CJS output calls the hook as_reactNativeReanimated.useAnimatedStyle(...), and the plugin recognises the bare identifier rather than the namespace member — so without the directive the pass finds nothing to transform. The explicit dependency arrays stay for the web, where the hook throws instead of aborting.