Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.4.0] - 2026-09-21
Changed
FloatingActionButton's documented default forpositionis corrected to'center'. The prop's JSDoc and its docs page both claimed'right'; the implementation has always centered a FAB that isn't told otherwise, stretching it between the screen's gutters, while'left'and'right'shrink it to its content. Only the documentation changes — passpositionexplicitly if you were relying on the documented value and never set it.- The Expo template no longer pins
@multinaire/expo-uito an exact release. Its dependency was^1.3.1— frozen at whatever version was current the last time the template was edited, so a fresh scaffold started a release or two behind and the pin needed bumping by hand on every release. It's now^1.0.0, sonpm installresolves the newest 1.x at scaffold time. The template'spackage-lock.jsonis unaffected either way: npm always strips it when packing a template, so a scaffolded project resolves the range fresh rather than inheriting a pinned version. A 2.0.0 will still need the range widened deliberately, which is the release where the template's own screens need revisiting anyway. - The Expo template's home screen is rebuilt against the patterns the library actually recommends. It was a single full-bleed column of hard-coded English, which is the one thing a template should never model: strings written as copy rather than keys still pass through i18n, where any that happen to collide with a key are silently replaced, and a layout with no breakpoints stretches a four-card list across a desktop window. The screen now reads its text from
src/assets/translations/en.json, registered throughMultinaireUI'stranslationsprop and typed by a new roottranslations.d.tsthat augmentsTranslationSchema— soTranslationKeyresolves to the keys the app ships, and adding a locale is a file plus two lines (see Localization). Layout comes offuseResponsiveDesignand is width-driven rather than branch-driven:maxWidths.contenton the outermostContainerisundefinedon a phone and800above it, so one style leaves mobile full-bleed and caps and centers everything else with the footer buttons still aligned to the content, whileisMobileswitches a wrapping grid's cells between'100%'and'49%'so the feature cards fall into two columns once there is room. Resizing the same tree needs nouseIsHydratedgate. The hero is aPlaceholder— the component already behind empty and error states — rather than a hand-stacked icon and heading. AThemeModeButton— aPopupofMenuitems overthemeModes, persisted bychangeThemeMode— is mounted as the stack header'sheaderRightrather than as a hand-rolled row inside the screen, which also gives the template its firstsrc/components/file and a worked example of app chrome living in the navigator, sinceuseScreenOptionsalready rendersStackHeader.AGENTS.md, the README, and the template's docs page are updated to match, including the SDK version they still reported as 56.
Fixed
- Documentation examples that no longer type-check. The components overview passed
padding="medium",gap="medium", andheight="medium"; those props take numbers, and the size tokens they were reaching for come offuseTheme().variables.MenuandPopup's pages still passedPopupatypeprop, removed back in 1.2.2.PickerButton's page drove aListPickerwith avisibleprop it has never had, rather than rendering it inside aPopup. All four are corrected.
[1.3.2] - 2026-09-11
Changed
- The native bottom sheet is now capped at
MAX_WIDTHS.modal(460px) above the mobile breakpoint.Popuppresents as a bottom sheet on native, and that sheet stretched from one edge of the window to the other — right on a phone, and increasingly wrong as the window grows: on an iPad aDialog's two buttons ended up half a screen apart and aListPicker's rows ran the full width with their checkmark stranded from their label. It is now inset to the cap and centered, the same figure the web dialog has always used, so the two presentations agree on how wide a popup gets. What is inset is the sheet itself rather than its content, which is what keeps everything inside it laying out exactly as it did at full width: the content region is still the sheet's own full-height box, so content that fills the sheet withflex: 1— aScrollContainerform, aListPickerin'list'mode — still fills it, and the sheet still sizes itself to content that doesn't. Nothing inPopup's API changes, and below the mobile breakpoint nothing changes at all. - A capped sheet dims the window itself. The sheet library paints its scrim across the sheet's own bounds, so an inset sheet would have dimmed only its own column and left the rest of the window bright and tappable.
Popupnow renders the backdrop for a capped sheet: a full-window dim that fades in with the sheet and dismisses it when pressed, so the gutters either side of the sheet behave as backdrop, which is what they look like. It is hosted at the app root, above the app content and below the sheets, and mounts only while a capped sheet is open — a full-width sheet keeps using the library's native scrim exactly as before, on every phone. One difference from the native scrim it stands in for: it fades on a fixed timing rather than tracking the sheet's position continuously, so dragging a sheet down holds the dim until the drag commits, then fades it. The web dialog's backdrop is now the same component — theModal's own'fade'still animates it there, so nothing about web changes — leaving one definition of what a popup's backdrop looks like and what pressing it does.
[1.3.1] - 2026-09-08
Added
TabContainergained aflexprop. The container, the page wrapper it clips, and the animated page inside it were all hardcoded toflex: 1— right for a tab view that owns its screen, wrong for one nested in aScrollContainer. React Native resolvesflex: 1toflex-basis: 0%, so inside a scroll view's auto-height content container the whole tab view resolves to zero height on web and the tabs simply don't appear (Yoga measures the same tree on native, which is why it looks fine there). All three now takeflexGrow: 1instead whenflexis omitted:flex-basisstaysauto, so the tab view still fills a parent of fixed height exactly as before, and inside aScrollContainerit sizes to the tabs rather than collapsing — the nested case needs no prop at all. Panels do have to carry their own height there, since aflex={1}panel inside a content-sized wrapper measures zero, so give each one an explicitheight(or content tall enough to establish one). Passflexto override that with realflexsemantics on all three at once —flex={1}to take a share of a fixed-height parent regardless of content,flex={0}to stop the tab view growing into leftover space. The page wrapper also merges thestyleyou pass rather than replacing its own, so astyleno longer drops the wrapper'soverflow: 'hidden'on the floor.StaticListContainercomponent (layouts). Non-scrolling twin ofListContainer, for the very common case where the list isn't the thing that scrolls.ListContainerwraps aFlatList, which brings its own scroll view, so nesting one inside aScrollContainermeans switching that off withscrollEnabled={false}— and on web react-native-web answers that withtouch-action: noneplusoverflow: hiddenon the list element. The browser then refuses to pan the parent scroller for any touch that starts over the list, so the screen simply cannot be scrolled on mobile web, and any content taller than the list's box is clipped away on top of that. A nested list is never virtualised anyway, so this one skips theFlatListand renders the rows straight into a pair of plain views. It takes exactly the same props asListContainer—data,renderItem,keyExtractor,numColumns,columnWrapperStyle,ItemSeparatorComponent,ListHeaderComponentandListFooterComponent(with their*ComponentStyle),ListEmptyComponent,isLoading/itemHeight/itemCount,flex,margin,padding,gap,style,contentContainerStyle— and lays them out the same way, down tostyleandcontentContainerStylelanding on the outer and inner view respectively,numColumnsrows sharing their width throughcolumnWrapperStyle(which still defaults to agap.mediumgap, and still leaves a short last row's cells stretched to fill it), skeletons takingflex: 1in a multi-column list, andkeyExtractordefaulting to an item'skey, then itsid, then its index. So swapping one for the other is the component name and nothing else. What it doesn't have is the scrolling:ref,horizontal,refreshControl,onEndReached, and the rest ofFlatList's scrolling and virtualization API are accepted, so a drop-in swap keeps type-checking, and ignored. Keep usingListContainerwhere the list is the scroller — it virtualises, so it remains the only one of the two fit for a long or unbounded list.AutoCompleteInputnow accepts the rest ofInput's props —autoFocusandonSubmit. The two components had grown apart:AutoCompleteInputdeclared its own subset of the field props rather than building onInputProps, so a search field with a suggestion list couldn't focus itself on mount or handle the keyboard's submit key, even though the plainInputunderneath it always could. Its props type now extendsInputPropsdirectly, which makesautoFocus(focus the field as it mounts, opening the keyboard — the point of a search sheet) andonSubmit(invoked with the current text when the user presses Enter, for accepting free text that matches no suggestion) available, and keeps the two field types in step asInputgains more.multiline,minHeight, andmaxHeightare deliberately excluded: a growing multi-line box makes no sense above a suggestion panel, andInputdrops the leading and trailing icons while multiline, which an autocomplete relies on. As onInput, passingonSubmitalone is enough to make the field editable — previously onlyonChangeTextwas.useThemeColorSyncanduseIsomorphicLayoutEffecthooks. Both back the web chrome sync described below, and both are exported for reuse.useThemeColorSync(themeMode, backgroundColor)points the browser's own chrome —<meta name="theme-color">, CSScolor-scheme, and thehtml/bodybackground — at a colour of your choosing;ThemeProvideralready calls it with the active theme'sbackground, so reach for it directly only when some other surface should drive the chrome instead, such as black bars around a full-screen photo viewer. It's a no-op with the same signature on native, so it's safe to call from shared code.useIsomorphicLayoutEffectisuseLayoutEffectthat falls back touseEffectduring server rendering: it silences React's SSR warning without giving up the pre-paint timing on the client, which is what keeps a hydration-time correction from flashing its placeholder. It was already inlined inuseIsHydratedand is now shared.
Changed
Popupnow mounts its content only while it is open. On native the sheet's subtree — the header, your children, and the keyboard spacer — was mounted for the lifetime of thePopup, whether or not the sheet had ever been opened. Every closed popup on a screen therefore ran its children's effects, held their subscriptions, and re-rendered along with them: a screen with a dozen popups paid for a dozen mountedListPickerlists,DateTimePickercalendars, and sets of keyboard listeners nobody could see. Opening now mounts the sheet already at its open detent, so it still animates in as before, and closing unmounts it only once the close animation has settled — the sheet animates out in full before its content goes away, rather than blanking the moment the index changes. Two consequences worth knowing: content state no longer survives a close, so each open starts from a clean slate (aListPicker's search text, aDateTimePicker's in-progress selection, a half-filled form), and effects inside popup children now run on open instead of on the surrounding screen's mount. Nothing about the API changes, and web is unaffected — React Native'sModalalready unmounts its children while hidden, so this brings native in line with what web has always done.- The Expo template ships an
app/+html.tsx.MultinaireUI's runtime chrome sync can only take effect once React has mounted, so the frame before that still belonged to the browser's default white. The template's web shell now declares the theme statically — aprefers-color-schemepair oftheme-colortags plus matchingcolor-schemeandhtml/bodybackgrounds, all read fromtheme.json— so the very first paint is already in the right scheme and the app takes over from there. It also carriesviewport-fit=coverand Expo Router'sScrollViewStyleReset, which keeps the document from scrolling soScrollContainerand friends own the scroll. Existing apps aren't affected; copy the file from the template (or the dark mode guide) to get the same first-paint behaviour.
Fixed
- The browser chrome now follows the app's theme mode on web, not the OS one.
ThemeProviderhas always driven the platform's own surfaces on native — the status bar viaexpo-status-bar, the Android navigation bar viaexpo-navigation-bar, the window background viaexpo-system-ui— but had no equivalent on web, where the only expression of the theme is whatever the app's+html.tsxrenders statically. That shell is server-rendered, so it can key offprefers-color-schemeand nothing else. The moment a user picked an explicit'light'or'dark'mode in-app (persisted under@theme) the two disagreed: Safari kept tinting its top bar and bottom toolbar from the OS-matchedtheme-color, and the over-scroll (rubber-band) areas above and below the document kept painting from the OS-matchedhtml/bodybackground — a light bar and footer wrapped around a dark app, and vice versa.ThemeProvidernow syncstheme-color,color-scheme, and thehtml/bodybackground to the resolved mode'sbackgroundcolour on every change. Because Safari ignores a mutatedcontentattribute on an existingtheme-colortag, and honours only the first tag whosemediamatches, the static pair from+html.tsxis removed and replaced with a single media-less tag that always wins. The work runs in a layout effect, so it resolves before the browser paints and the chrome never flashes the system colour. Nothing about the existing API changes and native is untouched — theuseThemeColorSynchook behind it is a no-op outside web.
[1.3.0] - 2026-08-21
Added
MarkdownInputcomponent (markdown). Rich text editor that reads and writes markdown, wrappingreact-native-enriched-markdown'sEnrichedMarkdownTextInputin the same field design as amultilineInput— label,requiredmarker, animated border, anderrorTextincluded. Formatting is driven by aMarkdownToolbarthat floats a gap above the keyboard while the editor has focus, tracking it as it animates in and out, wherever the field itself sits: bold, italic, underline, bullet list, and numbered list.toolbarPositionaligns that pill against the'left','center', or'right'(the default) of the screen. Anything the toolbar doesn't cover is also switched off in the native selection menu so the two never disagree — blocks the editor can't represent (code blocks, tables, blockquotes) belong inMarkdownTypography, and strikethrough, spoilers, and manual links are off (URLs typed into the editor are still auto-detected and linked). The editor is uncontrolled:defaultValueseeds it and content comes back throughonChangeMarkdown(omit that callback to skip markdown serialization entirely) or theref, which exposes the fullEnrichedMarkdownTextInputInstanceAPI.flexmakes the field fill its parent; otherwise it sizes to its content betweenminHeight(defaultheight.large) andmaxHeight(default three timesheight.large), scrolling internally past the cap. On web, wherereact-native-enriched-markdownships the read-only renderer only,MarkdownInputfalls back to amultilineInputover the raw markdown — same field design, no toolbar.MarkdownToolbarcomponent (markdown). The formatting toolbarMarkdownInputfloats above the keyboard, exported for when you want it somewhere else (docked at the bottom of a screen, for example). A shrink-wrapped, shadowedCardpill of centeredToggleIconButtons that holds no state: pass the editor's liveStyleStateviastateto get the active (filled) highlights, choose and order the buttons withactions, and handle each press throughonPressagainst aMarkdownInputref.useEnrichedMarkdownInputStylehook. Theme-derivedMarkdownTextInputStylefor the markdown editor — bold, italic, links, spoilers, per-level headings, and list spacing mapped to the active theme's colors, fonts, and variables, so formatted text in the editor reads the same as the rendered output fromMarkdownTypography.MarkdownInputapplies it internally and merges anymarkdownStyleyou pass on top; use the hook directly only to drive anEnrichedMarkdownTextInputof your own.CustomBulletListandCustomNumberedListicons. Iconsax has no list glyphs, so the two the markdown toolbar needs ship as custom icons. Like the other custom icons they're available toIconand anything taking anIconName, and they follow thecolorprop.MultinaireUInow hosts an overlay layer at the app root. A full-screen, touch-transparent layer rendered above the app content (and above bottom sheets) that components can render into when they need to be positioned against the window rather than against their own parent.MarkdownInput's toolbar uses it to stay pinned above the keyboard: rendered inline it could only be positioned relative to the field, which means measuring where the field currently sits — a figure that goes stale on every scroll, including the automatic scroll that lifts a focused field above the keyboard — and paint order still buried it under anything rendered after the field. A side effect is that the toolbar sits outside any surrounding scroll view, so it receives taps withoutkeyboardShouldPersistTapshandling. The layer adds oneflex: 1view at the root of the provider and renders nothing while empty.ToggleIconButtoncomponent (buttons). Compact icon-only button that carries an on/off state — quiet and borderless whileselectedis false, filled with the primary color when it's true. Sized to the theme'sheight.smallrather thanIconButton'sheight.medium, so a row of them fits inside a toolbar or card header. Stateless by design:selectedis held by the caller, which is what lets the same button drive a local toggle, a single-select group, or state read back from elsewhere —MarkdownToolbarbuilds its row from these, driven by the editor's cursor.InputgainedautoFocusandminHeightprops.autoFocusfocuses the field as it mounts, opening the keyboard — useful for a single-field form, a search sheet, or a field revealed by a press.minHeightsets the floor amultilinefield starts at, in pixels (it defaults to the theme'sheight.large, the previously hardcoded value), pairing with the existingmaxHeight. Both are ignored where they don't apply:minHeighton a single-line field, asmaxHeightalready was.Placeholdercomponent (common). Centered empty-state block for empty lists, "no results" screens, and error states: a requiredgraphicelement (anIcon,Photo, or custom illustration), atitleanddescriptionrendered throughTypography(so both are translated via theLocalizationProvider), and an optionalactionelement for the recovery button. Passflex={1}to fill the remaining space of the parent and center vertically within it; omit it to size to content, e.g. inside aCard.textAlignment(default'center') sets the alignment of the title and description — and of the block's content within its own width, though the graphic stays centered regardless — andpaddingaccepts the usualSpacingProps. Content is capped atBREAKPOINTS.mobile(640px) wide and centered, so it stays readable on tablet and web.SortableContainercomponent (layouts). List whose rows can be reordered by dragging: press and hold a row to lift it, the rows it passes slide out of the way, and dropping it callsonReorderwith the reordered array plus the indices the row moved between. It renders no row of its own — every row comes fromrenderItem, which also receivesisDraggingso the lifted row can style itself, exactly the division of labourListContainerhas with its ownrenderItem. Rows are absolutely positioned and the drag maths works in whole slots ofitemHeight + gap, so the container enforcesitemHeighton each row wrapper rather than measuring content; it defaults to the theme'sheight.medium, which is precisely the height of aListPickerItem, so the common case needs no configuration and anything taller needsitemHeightset to match.keyExtractoris required — reorder animations track rows by key, and index keys would make rows swap contents instead of moving.isLoadinganditemCountrender skeleton rows as onListContainer, anddisabledkeeps the list rendered but static. It does not scroll: it sizes to its rows and lays them out in place, so nest it in aScrollContainerwhen the list is taller than the screen — though that outer scroll won't follow a row dragged to the edge, which keeps a drag within the visible part of the list. A lifted row's travel is pinned to the first and last slot either way, so it can never be dragged out of the container and left floating over the rest of the screen. Nor should the list be a filtered subset, because a reorder inside one has no unambiguous meaning in the source array.SortableItemcomponent (layouts). The row wrapperSortableContainerputs around eachrenderItemresult, exported for building a different container over the same drag mechanics. It owns the pan gesture and the translation that carries a row between slots, and renders nothing visible of its own. Nothing is derived for you: every prop is required, and the sharedpositionsmap — the Reanimated shared value the rows both read and write, so that dragging one slides its neighbours aside — is yours to create and keep in step with your data.CustomGridicon. The six-dot grid glyph that marks a row as draggable. Iconsax has no drag-handle icon, so it ships as a custom one.SortableContainerrenders no row of its own, so nothing draws it for you — place it in yourrenderItemto advertise the gesture, as theSortableContainerexamples do. Like the other custom icons it's available toIconand anything taking anIconName, and it follows thecolorprop.IconNametype is now exported from the package root. The union of every value aniconprop accepts — the custom icon set plus all 993 Iconsax names — was spelled out inline on each prop that took one, so there was no name to import: typing your own wrapper aroundIcon, or a config object holding icon names, meant reaching intoIconProps['icon']. It's now a named type, and every icon-taking prop is declared with it (Icon'sicon,Accordion'sleading/trailing, and the icon props across the button, input, and popup types). The accepted values are unchanged — this is the name the props tables in these docs have always used.react-native-gesture-handleris now a required peer dependency (>=2.32.0, the version Expo SDK 57 pins), forSortableContainer's drag gesture. Install it withnpx expo install react-native-gesture-handler; nothing else is needed, becauseMultinaireUInow mounts theGestureHandlerRootViewthe library's gesture components require.MultinaireUInow mounts aGestureHandlerRootViewat the app root.GestureDetectorrefuses to recognize anything outside one, so without itSortableContainerwould throw on mount and every consumer would have to add the wrapper by hand. Nesting inside anotherGestureHandlerRootViewis harmless, so apps that already mount their own are unaffected. It is the outermost element of the provider and carriesflex: 1, which is what the wrapper needs to fill the screen.
Changed
SideBaris wider by default — 340px, up from 300px. Sidebar items with longer labels (or a label plus a trailing badge) were wrapping or truncating at 300px, and the stockpadding.mediumgutters left little room for the label itself. The extra 40px fits a typical two-word navigation label on one line without touching the button, icon, or gutter sizing. Override it as before withstyle={{ width }}.ListPickerItem'sonPressprop is now optional. Omitting it renders a non-interactive row: the underlyingCarddisables its pressable, so the row no longer dims on touch and a surrounding gesture — aSortableContainerdrag, for instance — receives the touch uncontested. Rows that passonPressbehave exactly as before.
[1.2.4] - 2026-08-09
Added
Popup's prop and context types are now exported from the package root.PopupProps,PopupType,PopupHeaderProps,PopupToggleButtonProps, andPopupContextwere defined but never re-exported, so consumers could renderPopup,PopupHeader, andPopupToggleButtonbut had no way to name their props — e.g. when writing a wrapper component or typing the return ofusePopup(). They're now importable from@multinaire/expo-uialongside the components themselves.Inputgained amaxHeightprop. Caps how tall amultilinefield grows, in pixels. Once the cap is reached the content scrolls inside the field instead of expanding further and pushing the rest of the layout down. Defaults to three times the theme'sheight.large(300px with the stock theme); passmaxHeightto override it. Ignored unlessmultilineis set.
Changed
- Buttons pulse more visibly while
isLoading. The loading pulse onButton,ActionButton,IconButton,MenuButton,FloatingActionButton, andSocialLoginButtonwas easy to miss: it only faded to 0.5 opacity. It now dips to 0.35, so a busy button reads as busy at a glance. The 800ms half-cycle is unchanged — the pulse is deeper, not faster. Layout, sizing, and the button's content are unchanged. Skeleton placeholders (Animate, and theisLoadingstate ofTypography,Icon,Photo, and friends) keep the original gentler pulse — a screenful of placeholders at the stronger setting is distracting. TypographywithonPressnow renders as a pressable.onPressused to be forwarded to the underlying RNText, which fires the handler but gives no visual feedback. The text is now wrapped in anAnimatedPressabledriven byusePressableInteraction, so it dims on press and hover like the button components.marginandflexmove to the wrapper (the innerTextkeepsflexso vertical alignment still works), leaving outer layout unchanged;testIDstays on theText. Text withoutonPressis unchanged.IconwithonPressnow renders as a pressable. PreviouslyonPresswas forwarded straight to the underlying SVG, which gave no press feedback and behaved inconsistently across platforms and between the custom and Iconsax icon sets. The icon is now wrapped in anAnimatedPressabledriven byusePressableInteraction, so it dims on press and hover likeButton,IconButton, andBadge.testIDmoves to the pressable whenonPressis set, and stays on the icon itself otherwise. Icons withoutonPressare unchanged.
Fixed
Accordionanimating to a stale height when its content changed. The content height was measured once on the first layout pass and locked in for the lifetime of the panel — it had to be, because the measured node sat in flow inside the clipped wrapper and so kept re-reporting the wrapper's animated height on every frame of an expand/collapse. Content that arrived or changed after that first pass (async-loaded text, a growing list, re-wrapping after a rotation or font-scale change) left the panel expanding to the old height, clipping or over-padding its content. The measured node is now absolutely positioned inside the wrapper, so it is always laid out at its own natural size regardless of how far the wrapper is collapsed;onLayouttherefore reports the real content height every time it changes, and an open panel animates smoothly to its new size. Measurement is held in a Reanimated shared value rather than component state, so re-measuring no longer costs a render.Inputwithmultilinenot growing with its content on web. On native the field expands as text wraps, but on webmultilinerenders a real<textarea>, whose height comes from itsrowsattribute rather than its content — so it stayed a fixed height and scrolled internally instead. The field now uses CSSfield-sizing: contenton web, letting the browser size it to its content. In browsers withoutfield-sizingsupport (notably Firefox) it keeps the previous fixed-height, internally-scrolling behavior. The scrollbar shown once amultilinefield reaches its cap is hidden on web viascrollbar-width: none; the field still scrolls, and native is unaffected.Input's wrapper row not following itsmultilinefield's height on web. Even once the field itself grew, the row around it stayed locked atheight.medium. The row cancelled its own fixed height withheight: undefined, which works under React Native'sStyleSheet.flatten(it copies theundefinedover the earlier value) but not understyleq, which react-native-web uses — there, a property whose value isundefinedis skipped without claiming the property, leaving the earlier height in force. It now usesheight: 'auto', which overrides consistently on both platforms.ListPickerrows with long labels wrapping out of their row. Each row is a fixed-heightCard(height.medium), but its label was free to wrap onto a second line — so a longtexteither overflowed the row's bounds or pushed its leading icon and selected checkmark out of vertical alignment, and rows stopped lining up with each other. Labels are now capped at a single line and truncate with an ellipsis instead of wrapping. Short labels are unaffected.
[1.2.3] - 2026-07-29
Changed
- BREAKING (behavioral):
ScrollContainerno longer fills its parent by default. Previously,ScrollContaineralways appliedflexGrow: 1internally — via React Native'sScrollViewbase style — even when noflexprop was passed, because an omittedflexcouldn't override it. If you rely on aScrollContainerwithoutflexto fill the remaining screen height, passflex={1}explicitly now. This matches the prop's documented default, which has always been "none".
Fixed
Popupfilling the entire screen on native when its children include aScrollContainer.Popupsizes itself on native by measuring how tall its content naturally is inside a full-window wrapper; aScrollContainer's always-onflexGrow: 1(see above) made it greedily expand to fill that wrapper, blowing the sheet out to full height regardless of how little content it held.ScrollContainernow only grows whenflexis explicitly passed, so it sizes to its content insidePopupby default.ListPickerresizing on web as content changed in'list'mode. On web there's no ancestor with a definite height forflex: 1to grow into (unlike native's bottom sheet), so the list shrink-wrapped to whatever was currently rendered and visibly resized on every keystroke while searching or as items loaded.ListPickernow gives its list a fixed height on web (8 rows) in'list'mode, so it stays stable and scrolls internally instead of resizing.Accordionnot expanding on native (iOS and Android). Its content height was measured viaonLayouton a child laid out inside an already-collapsed (height: 0)Animated.View; on native, a normal in-flow child of a collapsed animated parent never receives a real layout pass, so the measured height stayed stuck at0forever and the panel never grew (web was unaffected, since it goes through real CSS layout instead). Fixed by leaving the wrapper unclipped (and invisible) until the first real measurement lands, so that first layout pass happens naturally instead of being deferred, then switching to the normal animatedheight. Also addedcollapsable={false}to the wrapper to keep it from being optimized out of Android's native view hierarchy.
[1.2.2] - 2026-07-28
Added
- Native bottom sheet presentation for
Popup. On native,Popupnow renders as a true bottom sheet (via@swmansion/react-native-bottom-sheet) with drag-to-dismiss and keyboard-aware height, instead of aModal-based sheet simulation.MultinaireUInow wraps its children in aPopupWrapper(BottomSheetProvider) automatically — no additional setup is required. @swmansion/react-native-bottom-sheetpeer dependency (required,>=0.16.2).usePopup().isDialog. Convenience boolean shorthand forusePopup().type === 'dialog', used internally byDialog,Menu,ListPicker, andDateTimePickerand available to any custom popup content.
Changed
- BREAKING:
Popupno longer accepts atypeprop. Presentation is now fully automatic and platform-based — always a bottom sheet on native, always a centered dialog on web. Remove anytype="sheet"/type="dialog"passed toPopup.usePopup().typestill reports the resolved presentation (read-only) so children (e.g.Dialog,Menu,ListPicker,DateTimePicker) can adjust for bottom safe-area insets.
Fixed
Popupcontent overflowing on web. Modal content is now capped to 80% of the viewport height and clips overflow, instead of growing past the edges of the screen for tall content.AutoCompleteInputsuggestion panel layout jump. The suggestion list now expands/collapses by animating its measured content height, instead of snapping to full height and fading opacity in/out, which used to shift surrounding layout abruptly.Accordionexpand/collapse layout jump. Same fix as above — content height is now measured and animated instead of relying onLinearTransition/fade transitions.
[1.2.1] - 2026-07-27
Fixed
- peerDependencies version mismatch fixed an issue where peerDependencies for react* is mismatched.
[1.2.0] - 2026-07-27
Added
Accordioncomponent (common). Collapsible panel built onCard, with an animated header (leadingicon,title, optionalbadge, and atrailingchevron icon that defaults toArrowUp2/ArrowDown2based onactive). It's a controlled component — the parent ownsactivestate and toggles it viaonPress. Expand/collapse and layout shifts are animated withreact-native-reanimated.AutoCompleteInputcomponent (inputs), plus its row rendererAutoCompleteInputItem. A text input built on the sameInputWrapperasInput, with an animated suggestion panel (Card-based,react-native-reanimatedfade/layout transitions) that appears below it. Suggestions are supplied viaitemsand filtered either client-side withsearchPredicateor externally (e.g. server-side search wired up throughonChangeText, togglingisLoading). Selecting a suggestion viaonSelectauto-fills the input with itstext. SupportsminCharacters,maxVisibleItems(scrollable beyond that),noResultsText, and query highlighting in each row viahighlightMatch.- Markdown component category. New theme-aware markdown renderer, usable with just a
markdownstring (no hook wiring required):MarkdownTypography— a thin wrapper aroundreact-native-enriched-markdown'sEnrichedMarkdownText, typed directly from its props. Styling is theme-derived by default; pass a partialmarkdownStyleto override.useEnrichedMarkdownStylehook — exposes the theme-derivedMarkdownStyle(typed fromreact-native-enriched-markdown) used byMarkdownTypography, for customEnrichedMarkdownTextcomposition.
react-native-enriched-markdownpeer dependency (required,>=0.7.0).- New
src/constants.tsmodule, exported from the package root:BREAKPOINTSandMAX_WIDTHS(previously internal touseResponsiveDesign), plusIS_ANDROID,IS_IOS, andIS_WEBplatform flags. hoverBackgroundColorinTabButtonStyleandSideBarButtonStyle.TabBar,TabContainer, andSideBarbuttons now tint their background on hover (web/desktop only) when unfocused, distinct from the existing global press/hover opacity dim. Defaults to'base'for top/bottom tabs and'backgroundVariant'for sidebar items.Containergained matchingonHoverIn/onHoverOutprops to support this.
Fixed
- Rapid taps selecting button/tab text on web.
Typography(and everything built on it — buttons, tabs, badges, labels) rendered RN'sText, which is selectable by default on React Native Web (unlike native, whereTextisn't selectable unless opted in). A quick second tap landed as a browser double-click on selectable text and selected it instead of registering as a second press.Typographynow defaultsselectabletofalse, matching native behavior; passselectableexplicitly to opt back in for content that should support copy/select. - Remaining SSR hydration mismatch in
useResponsiveDesign. Its module-level width/height fallback still calledDimensions.get('window')directly instead of a fixed literal — on web this resolves to0/0on the server but the real viewport size on the client (read at bundle-evaluation time, before hydration), so the twoisHydrated-gated fallback branches held different values per environment. The fallback is now the deterministic literal0/0. Also fixed viewport width/height never updating after mount (useWindowDimensionschanges weren't propagated into state); the hook'sonLayoutnow only overrides the auto-tracked dimensions when explicitly wired up. - Light/dark theme flash on mount with SSR.
useIsHydratedpreviously flipped fromfalsetotrueviauseSyncExternalStore, which React resolves through a passive effect — running after the browser paints. This meantThemeProvider(anduseResponsiveDesign) briefly painted their placeholder value ('light'/0,0), then visibly repainted with the real value a frame later.useIsHydratednow flips inside a layout effect instead, which React guarantees resolves before the hydrated frame is painted — the placeholder is never actually shown on screen. - Persisted theme/language flash on mount, on web.
ThemeProviderandLocalizationProviderread the persisted theme mode/language viaAsyncStorage.getItem()inside auseEffect, so even though@react-native-async-storage/async-storage's web backend is justlocalStoragewrapped in aPromise, the applied value always landed a frame after the initial paint. On web, both providers now readlocalStoragedirectly and synchronously, from inside a layout effect — the persisted value is applied before the first paint instead of after it. Native is unaffected (still reads viaAsyncStoragein an effect, which has no equivalent paint-timing concern).
[1.1.1] - 2026-07-17
Added
useIsHydratedhook — returnsfalseon the server and during the client's hydration render,trueimmediately after mount. Exported so consumers can gate their own browser/device-only reads (window,matchMedia, etc.) behind hydration the same way the library now does internally.onLayouthandler inuseResponsiveDesign. - setting internal width/height instead of windows's default.
Fixed
- SSR hydration mismatches in
ThemeProvider,LocalizationProvider, anduseResponsiveDesign. On web,useColorScheme()anduseWindowDimensions()read real browser state synchronously during render, andLocalizationProviderresolved the persisted/system language from AsyncStorage — none of which the server can know, so the client's hydration render diverged from the server-rendered markup. Color scheme, viewport dimensions, and the initial language now render with deterministic values ('light',0/0, and the first configured/passed language respectively) until hydration completes, then switch to the real values immediately after mount. Translation resource bundles are also now registered synchronously during render instead of in auseEffect, sot()resolves correctly on the very first render everywhere.
[1.1.0] - 2026-07-13
Added
create-appCLI (@multinaire/create-app) — scaffolds a new project from a@multinaire/expo-uitemplate:npx @multinaire/create-app my-app. Supports--type expo(default, available now);--type react-nativeis reserved for an upcoming bare React Native CLI template.- Expo project template (
templates/expo-template) — a production-ready Expo Router app pre-wired with@multinaire/expo-ui,theme.json, i18n (i18next/react-i18next), Iconsax icons,DateTimePicker, and AsyncStorage-backed theme/language persistence. Ships its own.npmrc,AGENTS.md, and.claude/config. - Templates documentation — new "Templates" section in the docs site covering
create-appusage and the Expo Template's structure and pre-installed modules.
Changed
- Default
backgroundVarianttheme color (light & dark) now uses a translucent overlay (rgba(0, 0, 0, 0.04)light /rgba(255, 255, 255, 0.04)dark) instead of a flat gray, matching the value already used across templates and the Theme Builder. - GitHub Packages authentication docs standardized on the
PERSONAL_ACCESS_TOKENenvironment variable name (previouslyAUTH_TOKENin Installation), matching the.npmrcshipped in every template.
Fixed
package.jsonrepository/bugs/homepageURLs — corrected from the pre-renamemultinaire/uitomultinaire/expo-ui, matching the actual repository (stale since the 1.0.7 package rename).
CI
- Added a manual
workflow_dispatchworkflow (publish-create-app.yml) to publish@multinaire/create-appto GitHub Packages independently of the library's release cycle.
[1.0.8] - 2026-07-04
Fixed
- Published package was missing the
build/directory. The SDK 56 upgrade in 1.0.7 bumpedexpo-module-scriptsto a version whereexpo-module prepareno longer builds the package (it's now a no-op). Since nothing else innpm ci/npm publishran the build step, the 1.0.7 tarball published to GitHub Packages shipped without compiled output. Added aprepublishOnlyscript (expo-module prepublishOnly) so the library is always cleaned and rebuilt before publishing.
CI
- Bumped
actions/checkoutandactions/setup-nodetov5in both workflows, clearing the "Node.js 20 is deprecated" warning from GitHub Actions (v4 bundled a Node 20 runtime; v5 targets Node 24). - Added a manual
workflow_dispatchtrigger to the npm publish workflow so it can be run on demand instead of only on release creation.
[1.0.7] - 2026-07-03
Breaking Changes
Package renamed: @multinaire/ui → @multinaire/expo-ui. Update your dependency and every import:
npm uninstall @multinaire/ui
npm install @multinaire/expo-ui
// Before
import MultinaireUI, { Button, Typography } from '@multinaire/ui';
// After
import MultinaireUI, { Button, Typography } from '@multinaire/expo-ui';
Also update any declare module '@multinaire/ui' TranslationSchema augmentation and tsconfig.json path mappings to @multinaire/expo-ui. See the Migration Guide.
Changed
- Upgraded to Expo SDK 56. Bumped peer/dev dependencies accordingly:
expo~56.0.13,react19.2.3,react-native0.85.3,react-native-reanimated4.3.1,react-native-worklets0.8.3,react-native-screens4.25.2,react-native-svg15.15.4,typescript~6.0.3, and theexpo-*family to their SDK 56 releases. Minimum supported versions are now Expo SDK 56 and React Native 0.85. expo-navigation-bar/expo-status-bar/expo-system-uiusage updated for their SDK 56 APIs (declarative<NavigationBar>/<StatusBar>components instead of imperative calls). No change toMultinaireUI's public behavior.
Removed
@react-navigation/nativepeer dependency — navigation-related types are now sourced throughexpo-router's re-exports, so the package no longer needs to be installed separately alongsideexpo-router.
[1.0.6] - 2026-06-27
Added
Dialog.onCancelprop — optional() => voidcallback invoked when the cancel button is pressed (used whentype="confirm"). Previously the cancel button only calledonRequestCloseon the parentPopup.
Fixed
DancingScriptandOpenSansmissing locale font keys — theMultinaireUIfont map was missingmyandthlocale entries forDancingScriptandOpenSans, causing text to fall back to the wrong font when the active locale was Myanmar or Thai. The locale-specific keys are now registered.
[1.0.5] - 2026-06-26
Added
- NotoSans font family —
"NotoSans"is now a validfontFamilyvalue intheme.json. The library bundles NotoSans (Latin), NotoSans Myanmar, and NotoSans Thai. The correct glyph set is selected automatically based on the active locale:my→ Myanmar,th→ Thai, anything else → Latin. translate(key, options?)interpolation parameter —LocalizationContext.translatenow accepts an optionaloptions: Record<string, unknown>second argument for i18next variable substitution (e.g.translate('welcome', { appName: 'MyApp' })).
Fixed
Popupbackdrop tap on Android — tapping outside the modal no longer dismisses it on Android, preventing accidental dismissals. iOS behavior is unchanged.Inputmultiline padding — vertical padding on multiline inputs is now applied to the innerTextInputrather than the container, ensuring correct text alignment on all platforms.
[1.0.4] - 2026-06-02
Added
- Hover & press interaction states on all interactive components —
Button,ActionButton,IconButton,MenuButton,SocialLoginButton,FloatingActionButton,Card,Container(whenonPressis set),Checkbox, andInputWrappernow animate their opacity on hover (web/desktop) and press, with a smooth 150ms timing transition. The animation is automatically disabled while a button is loading or has noonPresshandler. usePressableInteractionhook exported from@multinaire/ui— returns ananimatedStyleand a set ofoverrideProps(onHoverIn,onHoverOut,onPressIn,onPressOut) you can spread onto anyPressableto add the same hover/press opacity feedback used by the built-in components.useButtonStyleshook is now exported from@multinaire/ui(previously an internal button helper) — resolves the background, foreground, and disabled styles for a given buttontype.
Changed
- All interactive components migrated from
TouchableOpacitytoPressable— this enables hover support on web/desktop and aligns with the React Native recommended interaction primitive. The previousactiveOpacityfeedback is replaced by the newusePressableInteractionanimation. AnimatedTouchableOpacityexport renamed toAnimatedPressable— anAnimated-wrappedPressable. Update any imports ofAnimatedTouchableOpacitytoAnimatedPressable. See the migration guide.- Minimum Node version raised to
>=22(was>=18).
Fixed
Popupsafe-area insets on Android — popup content is now wrapped in aSafeAreaProviderwith explicit initial metrics so the safe-area insets resolve correctly inside the Modal's Android window.Popupopen delay removed — the child content no longer waits on a 50mssetTimeoutbefore rendering, so popups open immediately.ListPickerlayout on Android — now renders using the dialog layout on Android (previously only when the popuptypewasdialog), preventing clipped sheet content.
[1.0.3] - 2026-05-20
Added
testIDauto-detection onTypography— whenchildrenis a string, the component automatically setstestIDto that string value so text elements are addressable in tests without extra configuration.testIDfallback toplaceholderonInput,PickerButton, andMediaPickerButton— when no explicittestIDis provided, theplaceholdervalue is used as the test identifier.- Built-in
testIDonInputWrapperclear button — always"clear-input". - Built-in
testIDmarkers onPagination— the animated label receives the current item value as itstestID; the focused step container receives"<item>-is-focused". - Built-in
testIDmarkers onDateTimePickernavigation buttons — previous icon is"previous", next icon is"next".
Changed
LocalizationProvider.defaultLanguage— the'system'option is renamed to'auto'. Update any explicitdefaultLanguage="system"todefaultLanguage="auto".LocalizationContext.language— type narrowed from'system' | LanguagetoLanguage; the active locale is always a concrete code.LocalizationContext.languages— type narrowed from('system' | Language)[]toLanguage[]; the array no longer includes'system'as a first entry.LocalizationContext.changeLanguage— parameter type narrowed from'system' | LanguagetoLanguage.
Fixed
StackHeadertop safe area — simplified the safe-area condition: the inset is now skipped only when the parent navigator is atabordrawer. The grandparent-stack detection introduced in 1.0.2 has been removed.
Removed
useScreenOptionsdeprecated properties —stack,stackWithOverrides,topTab,topTabWithOverrides,bottomTab,bottomTabWithOverrides,sideBar,sideBarWithOverrideshave been removed (deprecated in 1.0.2). UseuseScreenOptions(type).screenOptionsinstead.
[1.0.2] - 2026-05-09
Added
testIDprop on all interactive components —Button,ActionButton,IconButton,MenuButton,SocialLoginButton,FloatingActionButton,BottomTabButton,SideBarButton,TopTabButton,Container,Icon,Badge,Card,Input,InputWrapper,Checkbox,Toggle,PickerButton,MediaPickerButton,ListPickerItem. When atitleis available the prop defaults to the title value so most components are automatically addressable without extra configuration.- Built-in
testIDmarkers on structural elements: Popup overlay ("modal-overlay"), PopupHeader close button ("modal-close"), StackHeader back button ("navigation-back"), StackHeader close button ("navigation-close"). defaultLanguageprop onMultinaireUIandLocalizationProvider— set the initial language. Pass'system'(default) to auto-detect from the device locale viaexpo-localization, or a locale code (e.g.'fr') to hard-code the startup language.languagesarray inLocalizationContext— ordered list of all supported locales derived from thetranslationsprop. Always starts with'system'so you can offer an "Auto-detect" option in a language picker.defaultThemeModeprop onMultinaireUIandThemeProvider— set the initial theme mode ('system'|'light'|'dark'). Defaults to'system'.themeModesarray inThemeContext— always['system', 'light', 'dark'].TranslationSchemaaugmentation interface — declare your locale types once in a.d.tsfile and get type-safe locale codes (Language) and translation keys (TranslationKey) across all components and hooks.LanguageandTranslationKeytypes exported from@multinaire/ui.backTitleprop onStackHeader— override the back-button label. Defaults to'Back'.closeTitleprop onStackHeader— override the modal close-button label. Defaults to'Close'.confirmTitleprop onDateTimePicker— override the confirm button label. Defaults to'Confirm'.previousTitle,nextTitle,doneTitleprops onPageContainer— override the navigation button labels. Defaults are'Previous','Next','Done'.NavigatorTypetype ('stack' | 'top-tab' | 'bottom-tab' | 'side-bar') exported from@multinaire/ui.useScreenOptions(type)now accepts a navigator type and returns a unifiedscreenOptionsfunction plustitle(),tabBarLabel(), andtabBarIcon()helper functions for type-safe screen configuration.
Changed
Toggledefaultcolorchanged from'primary'to'success'. Passcolor="primary"explicitly to preserve the previous appearance.- String props that display user-facing text (button titles, input labels, placeholders, error messages, popup titles, etc.) are now typed as
TranslationKey. WhenTranslationSchemais augmented this provides compile-time checking that only defined keys are passed. Without augmentation the type staysstring. TypographyProps.childrenis now typed asTranslationKey | Exclude<ReactNode, string>. String literals passed as children must be valid translation keys whenTranslationSchemais in use.useScreenOptionslegacy return properties (stack,topTab,bottomTab,sideBar, and their*WithOverridesvariants) are deprecated. They continue to work but will be removed in a future minor version. Migrate touseScreenOptions(type).screenOptions.
Fixed
Toggleinternal state was updated even whenonChangewas not provided. State now only updates when a handler is present.- Tab bar icon color no longer falls back to
'transparent'; the color is correctly cast from the theme context. Containerwith bothsafeAreaEdgesandonPressnow throws a descriptive runtime error — these two props are mutually exclusive.StackHeadertop safe area — stacks nested inside another user stack no longer double-apply the top safe area inset. The header now checks for a grandparent navigator to detect genuine nesting, covering the common Expo Router case.StackHeaderdesktop title — when navigating back, the right-side container switched to a column layout but keptflex={1}, which prevented the title from rendering. The flex is now only applied when there is no back button (row layout).
Deprecated
useScreenOptions()return properties:stack,stackWithOverrides,topTab,topTabWithOverrides,bottomTab,bottomTabWithOverrides,sideBar,sideBarWithOverrides. UseuseScreenOptions(type).screenOptionsinstead.createTabSceenOptions(typo) renamed tocreateTabScreenOptions. The old name is no longer exported.
[1.0.1] - 2026-04-18
Changed
useUIrenamed touseTheme— the hook was shipped under the wrong name in 1.0.0. Update every call site:const { colors, variables, fonts } = useTheme().
[1.0.0] - 2026-04-17
Breaking Changes
Package renamed: @multinaire/multinaire-design → @multinaire/ui
All Multinaire prefixes removed from the public API. Update every import accordingly.
Provider / root entry point
| Before | After |
|---|---|
MultinaireDesignProvider (default export) | MultinaireUI (named + default export) |
MultinaireDesignProps | MultinaireUIProps |
Hooks
| Before | After |
|---|---|
useMultinaireTheme | useTheme |
useMultinaireThemeMode | useThemeMode |
useMultinaireLocalization | useLocalization |
useMultinaireModal | usePopup |
useMultinaireKeyboard | useKeyboard |
useMultinaireResponsiveDesign | useResponsiveDesign |
useMultinaireLoadingAnimation | useAnimate |
Icons
| Before | After |
|---|---|
MultinaireIcons | Icons |
Components — renamed
| Before | After |
|---|---|
MultinaireText | Typography |
MultinaireImage | Photo |
MultinaireLoading | Animate |
MultinaireScrollView | ScrollContainer |
MultinaireSwitch | Toggle |
MultinaireModal | Popup |
MultinaireModalHeader | PopupHeader |
MultinaireModalToggleButton | PopupToggleButton |
MultinaireKeyboardAvoidingView | KeyboardAvoidingContainer |
MultinaireListView | ListContainer |
MultinairePageView | PageContainer |
MultinaireTabView | TabContainer |
All other Multinaire* components drop the prefix (e.g. MultinaireButton → Button).
Prop types renamed to match their component names exactly:
| Old name | New name |
|---|---|
TextProps | TypographyProps |
ImageProps | PhotoProps |
SwitchProps | ToggleProps |
KeyboardAvoidingViewProps | KeyboardAvoidingContainerProps |
ScrollViewProps | ScrollContainerProps |
ListViewProps | ListContainerProps |
TabViewProps | TabContainerProps |
PageViewProps | PageContainerProps |
ListPickerItem | ListPickerItemData |
DatePickerProps | DateTimePickerProps |
Removed
MenuButton.typeprop ('default' | 'warning' | 'error') — foreground color is now alwaysonBackgroundButtonType.destructive— use'error'instead
Changed
Pageanimation changed from fade to directional slide (forward slides in from the right, backward from the left)
Added
MultinaireUIis now also the default export of@multinaire/ui
See the Migration Guide for a full upgrade walkthrough.