Skip to main content

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 for position is 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 — pass position explicitly if you were relying on the documented value and never set it.
  • The Expo template no longer pins @multinaire/expo-ui to 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, so npm install resolves the newest 1.x at scaffold time. The template's package-lock.json is 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 through MultinaireUI's translations prop and typed by a new root translations.d.ts that augments TranslationSchema — so TranslationKey resolves to the keys the app ships, and adding a locale is a file plus two lines (see Localization). Layout comes off useResponsiveDesign and is width-driven rather than branch-driven: maxWidths.content on the outermost Container is undefined on a phone and 800 above it, so one style leaves mobile full-bleed and caps and centers everything else with the footer buttons still aligned to the content, while isMobile switches 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 no useIsHydrated gate. The hero is a Placeholder — the component already behind empty and error states — rather than a hand-stacked icon and heading. A ThemeModeButton — a Popup of Menu items over themeModes, persisted by changeThemeMode — is mounted as the stack header's headerRight rather than as a hand-rolled row inside the screen, which also gives the template its first src/components/ file and a worked example of app chrome living in the navigator, since useScreenOptions already renders StackHeader. 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", and height="medium"; those props take numbers, and the size tokens they were reaching for come off useTheme().variables. Menu and Popup's pages still passed Popup a type prop, removed back in 1.2.2. PickerButton's page drove a ListPicker with a visible prop it has never had, rather than rendering it inside a Popup. 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. Popup presents 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 a Dialog's two buttons ended up half a screen apart and a ListPicker'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 with flex: 1 — a ScrollContainer form, a ListPicker in 'list' mode — still fills it, and the sheet still sizes itself to content that doesn't. Nothing in Popup'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. Popup now 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 — the Modal'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​

  • TabContainer gained a flex prop. The container, the page wrapper it clips, and the animated page inside it were all hardcoded to flex: 1 — right for a tab view that owns its screen, wrong for one nested in a ScrollContainer. React Native resolves flex: 1 to flex-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 take flexGrow: 1 instead when flex is omitted: flex-basis stays auto, so the tab view still fills a parent of fixed height exactly as before, and inside a ScrollContainer it 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 a flex={1} panel inside a content-sized wrapper measures zero, so give each one an explicit height (or content tall enough to establish one). Pass flex to override that with real flex semantics 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 the style you pass rather than replacing its own, so a style no longer drops the wrapper's overflow: 'hidden' on the floor.
  • StaticListContainer component (layouts). Non-scrolling twin of ListContainer, for the very common case where the list isn't the thing that scrolls. ListContainer wraps a FlatList, which brings its own scroll view, so nesting one inside a ScrollContainer means switching that off with scrollEnabled={false} — and on web react-native-web answers that with touch-action: none plus overflow: hidden on 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 the FlatList and renders the rows straight into a pair of plain views. It takes exactly the same props as ListContainer — data, renderItem, keyExtractor, numColumns, columnWrapperStyle, ItemSeparatorComponent, ListHeaderComponent and ListFooterComponent (with their *ComponentStyle), ListEmptyComponent, isLoading/itemHeight/itemCount, flex, margin, padding, gap, style, contentContainerStyle — and lays them out the same way, down to style and contentContainerStyle landing on the outer and inner view respectively, numColumns rows sharing their width through columnWrapperStyle (which still defaults to a gap.medium gap, and still leaves a short last row's cells stretched to fill it), skeletons taking flex: 1 in a multi-column list, and keyExtractor defaulting to an item's key, then its id, 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 of FlatList's scrolling and virtualization API are accepted, so a drop-in swap keeps type-checking, and ignored. Keep using ListContainer where the list is the scroller — it virtualises, so it remains the only one of the two fit for a long or unbounded list.
  • AutoCompleteInput now accepts the rest of Input's props — autoFocus and onSubmit. The two components had grown apart: AutoCompleteInput declared its own subset of the field props rather than building on InputProps, so a search field with a suggestion list couldn't focus itself on mount or handle the keyboard's submit key, even though the plain Input underneath it always could. Its props type now extends InputProps directly, which makes autoFocus (focus the field as it mounts, opening the keyboard — the point of a search sheet) and onSubmit (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 as Input gains more. multiline, minHeight, and maxHeight are deliberately excluded: a growing multi-line box makes no sense above a suggestion panel, and Input drops the leading and trailing icons while multiline, which an autocomplete relies on. As on Input, passing onSubmit alone is enough to make the field editable — previously only onChangeText was.
  • useThemeColorSync and useIsomorphicLayoutEffect hooks. 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">, CSS color-scheme, and the html/body background — at a colour of your choosing; ThemeProvider already calls it with the active theme's background, 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. useIsomorphicLayoutEffect is useLayoutEffect that falls back to useEffect during 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 in useIsHydrated and is now shared.

Changed​

  • Popup now 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 the Popup, 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 mounted ListPicker lists, DateTimePicker calendars, 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 (a ListPicker's search text, a DateTimePicker'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's Modal already 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 — a prefers-color-scheme pair of theme-color tags plus matching color-scheme and html/body backgrounds, all read from theme.json — so the very first paint is already in the right scheme and the app takes over from there. It also carries viewport-fit=cover and Expo Router's ScrollViewStyleReset, which keeps the document from scrolling so ScrollContainer and 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. ThemeProvider has always driven the platform's own surfaces on native — the status bar via expo-status-bar, the Android navigation bar via expo-navigation-bar, the window background via expo-system-ui — but had no equivalent on web, where the only expression of the theme is whatever the app's +html.tsx renders statically. That shell is server-rendered, so it can key off prefers-color-scheme and 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-matched theme-color, and the over-scroll (rubber-band) areas above and below the document kept painting from the OS-matched html/body background — a light bar and footer wrapped around a dark app, and vice versa. ThemeProvider now syncs theme-color, color-scheme, and the html/body background to the resolved mode's background colour on every change. Because Safari ignores a mutated content attribute on an existing theme-color tag, and honours only the first tag whose media matches, the static pair from +html.tsx is 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 — the useThemeColorSync hook behind it is a no-op outside web.

[1.3.0] - 2026-08-21​

Added​

  • MarkdownInput component (markdown). Rich text editor that reads and writes markdown, wrapping react-native-enriched-markdown's EnrichedMarkdownTextInput in the same field design as a multiline Input — label, required marker, animated border, and errorText included. Formatting is driven by a MarkdownToolbar that 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. toolbarPosition aligns 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 in MarkdownTypography, and strikethrough, spoilers, and manual links are off (URLs typed into the editor are still auto-detected and linked). The editor is uncontrolled: defaultValue seeds it and content comes back through onChangeMarkdown (omit that callback to skip markdown serialization entirely) or the ref, which exposes the full EnrichedMarkdownTextInputInstance API. flex makes the field fill its parent; otherwise it sizes to its content between minHeight (default height.large) and maxHeight (default three times height.large), scrolling internally past the cap. On web, where react-native-enriched-markdown ships the read-only renderer only, MarkdownInput falls back to a multiline Input over the raw markdown — same field design, no toolbar.
  • MarkdownToolbar component (markdown). The formatting toolbar MarkdownInput floats above the keyboard, exported for when you want it somewhere else (docked at the bottom of a screen, for example). A shrink-wrapped, shadowed Card pill of centered ToggleIconButtons that holds no state: pass the editor's live StyleState via state to get the active (filled) highlights, choose and order the buttons with actions, and handle each press through onPress against a MarkdownInput ref.
  • useEnrichedMarkdownInputStyle hook. Theme-derived MarkdownTextInputStyle for 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 from MarkdownTypography. MarkdownInput applies it internally and merges any markdownStyle you pass on top; use the hook directly only to drive an EnrichedMarkdownTextInput of your own.
  • CustomBulletList and CustomNumberedList icons. Iconsax has no list glyphs, so the two the markdown toolbar needs ship as custom icons. Like the other custom icons they're available to Icon and anything taking an IconName, and they follow the color prop.
  • MultinaireUI now 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 without keyboardShouldPersistTaps handling. The layer adds one flex: 1 view at the root of the provider and renders nothing while empty.
  • ToggleIconButton component (buttons). Compact icon-only button that carries an on/off state — quiet and borderless while selected is false, filled with the primary color when it's true. Sized to the theme's height.small rather than IconButton's height.medium, so a row of them fits inside a toolbar or card header. Stateless by design: selected is 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 — MarkdownToolbar builds its row from these, driven by the editor's cursor.
  • Input gained autoFocus and minHeight props. autoFocus focuses the field as it mounts, opening the keyboard — useful for a single-field form, a search sheet, or a field revealed by a press. minHeight sets the floor a multiline field starts at, in pixels (it defaults to the theme's height.large, the previously hardcoded value), pairing with the existing maxHeight. Both are ignored where they don't apply: minHeight on a single-line field, as maxHeight already was.
  • Placeholder component (common). Centered empty-state block for empty lists, "no results" screens, and error states: a required graphic element (an Icon, Photo, or custom illustration), a title and description rendered through Typography (so both are translated via the LocalizationProvider), and an optional action element for the recovery button. Pass flex={1} to fill the remaining space of the parent and center vertically within it; omit it to size to content, e.g. inside a Card. 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 — and padding accepts the usual SpacingProps. Content is capped at BREAKPOINTS.mobile (640px) wide and centered, so it stays readable on tablet and web.
  • SortableContainer component (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 calls onReorder with the reordered array plus the indices the row moved between. It renders no row of its own — every row comes from renderItem, which also receives isDragging so the lifted row can style itself, exactly the division of labour ListContainer has with its own renderItem. Rows are absolutely positioned and the drag maths works in whole slots of itemHeight + gap, so the container enforces itemHeight on each row wrapper rather than measuring content; it defaults to the theme's height.medium, which is precisely the height of a ListPickerItem, so the common case needs no configuration and anything taller needs itemHeight set to match. keyExtractor is required — reorder animations track rows by key, and index keys would make rows swap contents instead of moving. isLoading and itemCount render skeleton rows as on ListContainer, and disabled keeps the list rendered but static. It does not scroll: it sizes to its rows and lays them out in place, so nest it in a ScrollContainer when 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.
  • SortableItem component (layouts). The row wrapper SortableContainer puts around each renderItem result, 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 shared positions map — 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.
  • CustomGrid icon. The six-dot grid glyph that marks a row as draggable. Iconsax has no drag-handle icon, so it ships as a custom one. SortableContainer renders no row of its own, so nothing draws it for you — place it in your renderItem to advertise the gesture, as the SortableContainer examples do. Like the other custom icons it's available to Icon and anything taking an IconName, and it follows the color prop.
  • IconName type is now exported from the package root. The union of every value an icon prop 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 around Icon, or a config object holding icon names, meant reaching into IconProps['icon']. It's now a named type, and every icon-taking prop is declared with it (Icon's icon, Accordion's leading/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-handler is now a required peer dependency (>=2.32.0, the version Expo SDK 57 pins), for SortableContainer's drag gesture. Install it with npx expo install react-native-gesture-handler; nothing else is needed, because MultinaireUI now mounts the GestureHandlerRootView the library's gesture components require.
  • MultinaireUI now mounts a GestureHandlerRootView at the app root. GestureDetector refuses to recognize anything outside one, so without it SortableContainer would throw on mount and every consumer would have to add the wrapper by hand. Nesting inside another GestureHandlerRootView is harmless, so apps that already mount their own are unaffected. It is the outermost element of the provider and carries flex: 1, which is what the wrapper needs to fill the screen.

Changed​

  • SideBar is 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 stock padding.medium gutters 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 with style={{ width }}.
  • ListPickerItem's onPress prop is now optional. Omitting it renders a non-interactive row: the underlying Card disables its pressable, so the row no longer dims on touch and a surrounding gesture — a SortableContainer drag, for instance — receives the touch uncontested. Rows that pass onPress behave 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, and PopupContext were defined but never re-exported, so consumers could render Popup, PopupHeader, and PopupToggleButton but had no way to name their props — e.g. when writing a wrapper component or typing the return of usePopup(). They're now importable from @multinaire/expo-ui alongside the components themselves.
  • Input gained a maxHeight prop. Caps how tall a multiline field 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's height.large (300px with the stock theme); pass maxHeight to override it. Ignored unless multiline is set.

Changed​

  • Buttons pulse more visibly while isLoading. The loading pulse on Button, ActionButton, IconButton, MenuButton, FloatingActionButton, and SocialLoginButton was 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 the isLoading state of Typography, Icon, Photo, and friends) keep the original gentler pulse — a screenful of placeholders at the stronger setting is distracting.
  • Typography with onPress now renders as a pressable. onPress used to be forwarded to the underlying RN Text, which fires the handler but gives no visual feedback. The text is now wrapped in an AnimatedPressable driven by usePressableInteraction, so it dims on press and hover like the button components. margin and flex move to the wrapper (the inner Text keeps flex so vertical alignment still works), leaving outer layout unchanged; testID stays on the Text. Text without onPress is unchanged.
  • Icon with onPress now renders as a pressable. Previously onPress was 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 an AnimatedPressable driven by usePressableInteraction, so it dims on press and hover like Button, IconButton, and Badge. testID moves to the pressable when onPress is set, and stays on the icon itself otherwise. Icons without onPress are unchanged.

Fixed​

  • Accordion animating 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; onLayout therefore 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.
  • Input with multiline not growing with its content on web. On native the field expands as text wraps, but on web multiline renders a real <textarea>, whose height comes from its rows attribute rather than its content — so it stayed a fixed height and scrolled internally instead. The field now uses CSS field-sizing: content on web, letting the browser size it to its content. In browsers without field-sizing support (notably Firefox) it keeps the previous fixed-height, internally-scrolling behavior. The scrollbar shown once a multiline field reaches its cap is hidden on web via scrollbar-width: none; the field still scrolls, and native is unaffected.
  • Input's wrapper row not following its multiline field's height on web. Even once the field itself grew, the row around it stayed locked at height.medium. The row cancelled its own fixed height with height: undefined, which works under React Native's StyleSheet.flatten (it copies the undefined over the earlier value) but not under styleq, which react-native-web uses — there, a property whose value is undefined is skipped without claiming the property, leaving the earlier height in force. It now uses height: 'auto', which overrides consistently on both platforms.
  • ListPicker rows with long labels wrapping out of their row. Each row is a fixed-height Card (height.medium), but its label was free to wrap onto a second line — so a long text either 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): ScrollContainer no longer fills its parent by default. Previously, ScrollContainer always applied flexGrow: 1 internally — via React Native's ScrollView base style — even when no flex prop was passed, because an omitted flex couldn't override it. If you rely on a ScrollContainer without flex to fill the remaining screen height, pass flex={1} explicitly now. This matches the prop's documented default, which has always been "none".

Fixed​

  • Popup filling the entire screen on native when its children include a ScrollContainer. Popup sizes itself on native by measuring how tall its content naturally is inside a full-window wrapper; a ScrollContainer's always-on flexGrow: 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. ScrollContainer now only grows when flex is explicitly passed, so it sizes to its content inside Popup by default.
  • ListPicker resizing on web as content changed in 'list' mode. On web there's no ancestor with a definite height for flex: 1 to 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. ListPicker now gives its list a fixed height on web (8 rows) in 'list' mode, so it stays stable and scrolls internally instead of resizing.
  • Accordion not expanding on native (iOS and Android). Its content height was measured via onLayout on 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 at 0 forever 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 animated height. Also added collapsable={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, Popup now renders as a true bottom sheet (via @swmansion/react-native-bottom-sheet) with drag-to-dismiss and keyboard-aware height, instead of a Modal-based sheet simulation. MultinaireUI now wraps its children in a PopupWrapper (BottomSheetProvider) automatically — no additional setup is required.
  • @swmansion/react-native-bottom-sheet peer dependency (required, >=0.16.2).
  • usePopup().isDialog. Convenience boolean shorthand for usePopup().type === 'dialog', used internally by Dialog, Menu, ListPicker, and DateTimePicker and available to any custom popup content.

Changed​

  • BREAKING: Popup no longer accepts a type prop. Presentation is now fully automatic and platform-based — always a bottom sheet on native, always a centered dialog on web. Remove any type="sheet" / type="dialog" passed to Popup. usePopup().type still reports the resolved presentation (read-only) so children (e.g. Dialog, Menu, ListPicker, DateTimePicker) can adjust for bottom safe-area insets.

Fixed​

  • Popup content 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.
  • AutoCompleteInput suggestion 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.
  • Accordion expand/collapse layout jump. Same fix as above — content height is now measured and animated instead of relying on LinearTransition/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​

  • Accordion component (common). Collapsible panel built on Card, with an animated header (leading icon, title, optional badge, and a trailing chevron icon that defaults to ArrowUp2/ArrowDown2 based on active). It's a controlled component — the parent owns active state and toggles it via onPress. Expand/collapse and layout shifts are animated with react-native-reanimated.
  • AutoCompleteInput component (inputs), plus its row renderer AutoCompleteInputItem. A text input built on the same InputWrapper as Input, with an animated suggestion panel (Card-based, react-native-reanimated fade/layout transitions) that appears below it. Suggestions are supplied via items and filtered either client-side with searchPredicate or externally (e.g. server-side search wired up through onChangeText, toggling isLoading). Selecting a suggestion via onSelect auto-fills the input with its text. Supports minCharacters, maxVisibleItems (scrollable beyond that), noResultsText, and query highlighting in each row via highlightMatch.
  • Markdown component category. New theme-aware markdown renderer, usable with just a markdown string (no hook wiring required):
    • MarkdownTypography — a thin wrapper around react-native-enriched-markdown's EnrichedMarkdownText, typed directly from its props. Styling is theme-derived by default; pass a partial markdownStyle to override.
    • useEnrichedMarkdownStyle hook — exposes the theme-derived MarkdownStyle (typed from react-native-enriched-markdown) used by MarkdownTypography, for custom EnrichedMarkdownText composition.
  • react-native-enriched-markdown peer dependency (required, >=0.7.0).
  • New src/constants.ts module, exported from the package root: BREAKPOINTS and MAX_WIDTHS (previously internal to useResponsiveDesign), plus IS_ANDROID, IS_IOS, and IS_WEB platform flags.
  • hoverBackgroundColor in TabButtonStyle and SideBarButtonStyle. TabBar, TabContainer, and SideBar buttons 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. Container gained matching onHoverIn/onHoverOut props to support this.

Fixed​

  • Rapid taps selecting button/tab text on web. Typography (and everything built on it — buttons, tabs, badges, labels) rendered RN's Text, which is selectable by default on React Native Web (unlike native, where Text isn'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. Typography now defaults selectable to false, matching native behavior; pass selectable explicitly to opt back in for content that should support copy/select.
  • Remaining SSR hydration mismatch in useResponsiveDesign. Its module-level width/height fallback still called Dimensions.get('window') directly instead of a fixed literal — on web this resolves to 0/0 on the server but the real viewport size on the client (read at bundle-evaluation time, before hydration), so the two isHydrated-gated fallback branches held different values per environment. The fallback is now the deterministic literal 0/0. Also fixed viewport width/height never updating after mount (useWindowDimensions changes weren't propagated into state); the hook's onLayout now only overrides the auto-tracked dimensions when explicitly wired up.
  • Light/dark theme flash on mount with SSR. useIsHydrated previously flipped from false to true via useSyncExternalStore, which React resolves through a passive effect — running after the browser paints. This meant ThemeProvider (and useResponsiveDesign) briefly painted their placeholder value ('light' / 0,0), then visibly repainted with the real value a frame later. useIsHydrated now 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. ThemeProvider and LocalizationProvider read the persisted theme mode/language via AsyncStorage.getItem() inside a useEffect, so even though @react-native-async-storage/async-storage's web backend is just localStorage wrapped in a Promise, the applied value always landed a frame after the initial paint. On web, both providers now read localStorage directly 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 via AsyncStorage in an effect, which has no equivalent paint-timing concern).

[1.1.1] - 2026-07-17​

Added​

  • useIsHydrated hook — returns false on the server and during the client's hydration render, true immediately 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.
  • onLayout handler in useResponsiveDesign. - setting internal width/height instead of windows's default.

Fixed​

  • SSR hydration mismatches in ThemeProvider, LocalizationProvider, and useResponsiveDesign. On web, useColorScheme() and useWindowDimensions() read real browser state synchronously during render, and LocalizationProvider resolved 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 a useEffect, so t() resolves correctly on the very first render everywhere.

[1.1.0] - 2026-07-13​

Added​

  • create-app CLI (@multinaire/create-app) — scaffolds a new project from a @multinaire/expo-ui template: npx @multinaire/create-app my-app. Supports --type expo (default, available now); --type react-native is 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-app usage and the Expo Template's structure and pre-installed modules.

Changed​

  • Default backgroundVariant theme 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_TOKEN environment variable name (previously AUTH_TOKEN in Installation), matching the .npmrc shipped in every template.

Fixed​

  • package.json repository/bugs/homepage URLs — corrected from the pre-rename multinaire/ui to multinaire/expo-ui, matching the actual repository (stale since the 1.0.7 package rename).

CI​

  • Added a manual workflow_dispatch workflow (publish-create-app.yml) to publish @multinaire/create-app to 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 bumped expo-module-scripts to a version where expo-module prepare no longer builds the package (it's now a no-op). Since nothing else in npm ci/npm publish ran the build step, the 1.0.7 tarball published to GitHub Packages shipped without compiled output. Added a prepublishOnly script (expo-module prepublishOnly) so the library is always cleaned and rebuilt before publishing.

CI​

  • Bumped actions/checkout and actions/setup-node to v5 in 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_dispatch trigger 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, react 19.2.3, react-native 0.85.3, react-native-reanimated 4.3.1, react-native-worklets 0.8.3, react-native-screens 4.25.2, react-native-svg 15.15.4, typescript ~6.0.3, and the expo-* 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-ui usage updated for their SDK 56 APIs (declarative <NavigationBar>/<StatusBar> components instead of imperative calls). No change to MultinaireUI's public behavior.

Removed​

  • @react-navigation/native peer dependency — navigation-related types are now sourced through expo-router's re-exports, so the package no longer needs to be installed separately alongside expo-router.

[1.0.6] - 2026-06-27​

Added​

  • Dialog.onCancel prop — optional () => void callback invoked when the cancel button is pressed (used when type="confirm"). Previously the cancel button only called onRequestClose on the parent Popup.

Fixed​

  • DancingScript and OpenSans missing locale font keys — the MultinaireUI font map was missing my and th locale entries for DancingScript and OpenSans, 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 valid fontFamily value in theme.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.translate now accepts an optional options: Record<string, unknown> second argument for i18next variable substitution (e.g. translate('welcome', { appName: 'MyApp' })).

Fixed​

  • Popup backdrop tap on Android — tapping outside the modal no longer dismisses it on Android, preventing accidental dismissals. iOS behavior is unchanged.
  • Input multiline padding — vertical padding on multiline inputs is now applied to the inner TextInput rather 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 (when onPress is set), Checkbox, and InputWrapper now 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 no onPress handler.
  • usePressableInteraction hook exported from @multinaire/ui — returns an animatedStyle and a set of overrideProps (onHoverIn, onHoverOut, onPressIn, onPressOut) you can spread onto any Pressable to add the same hover/press opacity feedback used by the built-in components.
  • useButtonStyles hook is now exported from @multinaire/ui (previously an internal button helper) — resolves the background, foreground, and disabled styles for a given button type.

Changed​

  • All interactive components migrated from TouchableOpacity to Pressable — this enables hover support on web/desktop and aligns with the React Native recommended interaction primitive. The previous activeOpacity feedback is replaced by the new usePressableInteraction animation.
  • AnimatedTouchableOpacity export renamed to AnimatedPressable — an Animated-wrapped Pressable. Update any imports of AnimatedTouchableOpacity to AnimatedPressable. See the migration guide.
  • Minimum Node version raised to >=22 (was >=18).

Fixed​

  • Popup safe-area insets on Android — popup content is now wrapped in a SafeAreaProvider with explicit initial metrics so the safe-area insets resolve correctly inside the Modal's Android window.
  • Popup open delay removed — the child content no longer waits on a 50ms setTimeout before rendering, so popups open immediately.
  • ListPicker layout on Android — now renders using the dialog layout on Android (previously only when the popup type was dialog), preventing clipped sheet content.

[1.0.3] - 2026-05-20​

Added​

  • testID auto-detection on Typography — when children is a string, the component automatically sets testID to that string value so text elements are addressable in tests without extra configuration.
  • testID fallback to placeholder on Input, PickerButton, and MediaPickerButton — when no explicit testID is provided, the placeholder value is used as the test identifier.
  • Built-in testID on InputWrapper clear button — always "clear-input".
  • Built-in testID markers on Pagination — the animated label receives the current item value as its testID; the focused step container receives "<item>-is-focused".
  • Built-in testID markers on DateTimePicker navigation buttons — previous icon is "previous", next icon is "next".

Changed​

  • LocalizationProvider.defaultLanguage — the 'system' option is renamed to 'auto'. Update any explicit defaultLanguage="system" to defaultLanguage="auto".
  • LocalizationContext.language — type narrowed from 'system' | Language to Language; the active locale is always a concrete code.
  • LocalizationContext.languages — type narrowed from ('system' | Language)[] to Language[]; the array no longer includes 'system' as a first entry.
  • LocalizationContext.changeLanguage — parameter type narrowed from 'system' | Language to Language.

Fixed​

  • StackHeader top safe area — simplified the safe-area condition: the inset is now skipped only when the parent navigator is a tab or drawer. The grandparent-stack detection introduced in 1.0.2 has been removed.

Removed​

  • useScreenOptions deprecated properties — stack, stackWithOverrides, topTab, topTabWithOverrides, bottomTab, bottomTabWithOverrides, sideBar, sideBarWithOverrides have been removed (deprecated in 1.0.2). Use useScreenOptions(type).screenOptions instead.

[1.0.2] - 2026-05-09​

Added​

  • testID prop 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 a title is available the prop defaults to the title value so most components are automatically addressable without extra configuration.
  • Built-in testID markers on structural elements: Popup overlay ("modal-overlay"), PopupHeader close button ("modal-close"), StackHeader back button ("navigation-back"), StackHeader close button ("navigation-close").
  • defaultLanguage prop on MultinaireUI and LocalizationProvider — set the initial language. Pass 'system' (default) to auto-detect from the device locale via expo-localization, or a locale code (e.g. 'fr') to hard-code the startup language.
  • languages array in LocalizationContext — ordered list of all supported locales derived from the translations prop. Always starts with 'system' so you can offer an "Auto-detect" option in a language picker.
  • defaultThemeMode prop on MultinaireUI and ThemeProvider — set the initial theme mode ('system' | 'light' | 'dark'). Defaults to 'system'.
  • themeModes array in ThemeContext — always ['system', 'light', 'dark'].
  • TranslationSchema augmentation interface — declare your locale types once in a .d.ts file and get type-safe locale codes (Language) and translation keys (TranslationKey) across all components and hooks.
  • Language and TranslationKey types exported from @multinaire/ui.
  • backTitle prop on StackHeader — override the back-button label. Defaults to 'Back'.
  • closeTitle prop on StackHeader — override the modal close-button label. Defaults to 'Close'.
  • confirmTitle prop on DateTimePicker — override the confirm button label. Defaults to 'Confirm'.
  • previousTitle, nextTitle, doneTitle props on PageContainer — override the navigation button labels. Defaults are 'Previous', 'Next', 'Done'.
  • NavigatorType type ('stack' | 'top-tab' | 'bottom-tab' | 'side-bar') exported from @multinaire/ui.
  • useScreenOptions(type) now accepts a navigator type and returns a unified screenOptions function plus title(), tabBarLabel(), and tabBarIcon() helper functions for type-safe screen configuration.

Changed​

  • Toggle default color changed from 'primary' to 'success'. Pass color="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. When TranslationSchema is augmented this provides compile-time checking that only defined keys are passed. Without augmentation the type stays string.
  • TypographyProps.children is now typed as TranslationKey | Exclude<ReactNode, string>. String literals passed as children must be valid translation keys when TranslationSchema is in use.
  • useScreenOptions legacy return properties (stack, topTab, bottomTab, sideBar, and their *WithOverrides variants) are deprecated. They continue to work but will be removed in a future minor version. Migrate to useScreenOptions(type).screenOptions.

Fixed​

  • Toggle internal state was updated even when onChange was 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.
  • Container with both safeAreaEdges and onPress now throws a descriptive runtime error — these two props are mutually exclusive.
  • StackHeader top 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.
  • StackHeader desktop title — when navigating back, the right-side container switched to a column layout but kept flex={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. Use useScreenOptions(type).screenOptions instead.
  • createTabSceenOptions (typo) renamed to createTabScreenOptions. The old name is no longer exported.

[1.0.1] - 2026-04-18​

Changed​

  • useUI renamed to useTheme — 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

BeforeAfter
MultinaireDesignProvider (default export)MultinaireUI (named + default export)
MultinaireDesignPropsMultinaireUIProps

Hooks

BeforeAfter
useMultinaireThemeuseTheme
useMultinaireThemeModeuseThemeMode
useMultinaireLocalizationuseLocalization
useMultinaireModalusePopup
useMultinaireKeyboarduseKeyboard
useMultinaireResponsiveDesignuseResponsiveDesign
useMultinaireLoadingAnimationuseAnimate

Icons

BeforeAfter
MultinaireIconsIcons

Components — renamed

BeforeAfter
MultinaireTextTypography
MultinaireImagePhoto
MultinaireLoadingAnimate
MultinaireScrollViewScrollContainer
MultinaireSwitchToggle
MultinaireModalPopup
MultinaireModalHeaderPopupHeader
MultinaireModalToggleButtonPopupToggleButton
MultinaireKeyboardAvoidingViewKeyboardAvoidingContainer
MultinaireListViewListContainer
MultinairePageViewPageContainer
MultinaireTabViewTabContainer

All other Multinaire* components drop the prefix (e.g. MultinaireButton → Button).

Prop types renamed to match their component names exactly:

Old nameNew name
TextPropsTypographyProps
ImagePropsPhotoProps
SwitchPropsToggleProps
KeyboardAvoidingViewPropsKeyboardAvoidingContainerProps
ScrollViewPropsScrollContainerProps
ListViewPropsListContainerProps
TabViewPropsTabContainerProps
PageViewPropsPageContainerProps
ListPickerItemListPickerItemData
DatePickerPropsDateTimePickerProps

Removed​

  • MenuButton.type prop ('default' | 'warning' | 'error') — foreground color is now always onBackground
  • ButtonType.destructive — use 'error' instead

Changed​

  • Page animation changed from fade to directional slide (forward slides in from the right, backward from the left)

Added​

  • MultinaireUI is now also the default export of @multinaire/ui

See the Migration Guide for a full upgrade walkthrough.