rn-motion-ui
v7.14.0
Published
Animated React Native + React Native Web UI components, styled with Tailwind via uniwind and powered by Moti/Reanimated.
Maintainers
Readme
rn-motion-ui
Animated React Native + React Native Web UI components, styled with Tailwind via uniwind and powered by an in-package Moti / Reanimated 4 layer.
The components are written as React Native primitives, so they run on native
(iOS/Android via Expo or bare RN) and on the web through react-native-web.
Install
npm install rn-motion-ui
# or: bun add rn-motion-uiPeer dependencies
Install these in your app (they are not bundled):
npm install react react-native react-native-reanimated react-native-gesture-handler| Peer | Supported range |
| ------------------------------ | --------------- |
| react | >=18.0.0 |
| react-native | >=0.76.0 |
| react-native-reanimated | >=4.0.0 |
| react-native-gesture-handler | >=2.0.0 |
Optional peer — backdrop blur
Overlay scrims (MorphingModal, AdaptiveModal, Drawer, HoldMenu, …)
frost their backdrop when the optional peer
@danielsaraldi/react-native-blur-view
(>=3.0.0) is installed. If it is absent, they degrade to a plain translucent
dim automatically.
To enable real blur, declare it as a regular dependency of your app:
npm install @danielsaraldi/react-native-blur-view
# or: bun add @danielsaraldi/react-native-blur-viewThe native module must be autolinked, not merely present in node_modules.
Bun, pnpm and yarn-berry auto-install optional peers, so the package can sit in
node_modules without being declared as a dependency — in that state its native
BlurView/TargetView are never registered, and blur silently degrades to the
dim. Installing it as a real dependency lets autolinking register the native
module.
On Android, also wrap your app root in BlurProvider (exported at
rn-motion-ui/overlay/blur-provider) — the peer's BlurView blurs a
BlurTarget rather than whatever sits behind it, and the provider supplies that
target. On iOS no provider is needed (the BlurView is a UIVisualEffectView
that blurs behind itself).
Blur constraints (for custom overlays)
The shipped scrims handle three quirks of the peer's BlurView for you; a
custom overlay that renders its own BlurView re-discovers them the hard way:
- Android blurs a target, not the backdrop. The peer's
BlurViewblurs the<BlurTarget>it is pointed at, which is why Android needs theBlurProviderwrapper above — the shipped scrims read its target through the provider context. Without the provider (or the peer) the scrim degrades to the plain dim. - The wrapper hardcodes
zIndex: 10. The peer setszIndex: 10in its container style, lifting the frost above sibling content and "frosting it away" on iOS. Flatten it back tozIndex: 0so the pane's content sits above the frost (the shippedSurfaceglass mode does this). - Never nest a
BlurViewinside its own target on Android. ABlurViewthat is a descendant of theBlurTargetit blurs cycles the RenderNode graph and crashes (SIGSEGV). Render the backdrop outside the target — through theBlurProvider's overlay host — or let the scrim degrade to the dim.
Consumer setup
This package ships TypeScript source (no prebuilt bundle). Your bundler already transpiles your own app code, so it will transpile these components the same way — but two build-time plugins are required for the components to work correctly:
react-native-reanimated/plugin(Babel) — transforms Reanimated worklets. Without it, easing/animation worklets throw at runtime.uniwind— resolves theclassNameprops on RN components to styles at build time. Without it,classNameis ignored.
Native (Expo / Metro)
babel.config.js:
module.exports = {
presets: ["babel-preset-expo"],
plugins: ["react-native-reanimated/plugin"],
};Set up uniwind per its docs (Metro transformer + a global.css Tailwind
entry).
Web (Vite / react-native-web)
Alias react-native → react-native-web, run the Reanimated Babel plugin
through your React transform, and add the uniwind Vite plugin. See this repo's
storybook/web/.storybook/main.ts for a working reference config.
Usage
Every module is a subpath export — import directly from its path. There is
no barrel index export by design, so bundlers only pull in what you import.
import { Button } from "rn-motion-ui/button";
import { Switch } from "rn-motion-ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "rn-motion-ui/tabs";
import { useReducedMotion } from "rn-motion-ui/hooks/use-reduced-motion";
import { MotiView } from "rn-motion-ui/moti/view";
import { AnimatePresence } from "rn-motion-ui/moti/presence";
import { hasKey } from "rn-motion-ui/utils/typeguards";Available exports
Subpaths are namespaced by category:
- UI components — flat, e.g.
rn-motion-ui/button,rn-motion-ui/tabs,rn-motion-ui/bottom-sheet, … - Hooks —
rn-motion-ui/hooks/<name>(use-reduced-motion,use-mount-effect,use-in-view, …) - Moti primitives —
rn-motion-ui/moti/<name>(view,text,motify,presence,pressable, …) - Utils —
rn-motion-ui/utils/typeguards
UI components
| Subpath | Component / hook |
| ------------------------------ | ------------------------------------------------------------------------ |
| /action-row | ActionRow |
| /action-row-group | ActionRowGroup |
| /button | Button |
| /button-group | ButtonGroup |
| /button-swap | ButtonSwap, ButtonSwapText, ButtonSwapIcon |
| /close-button | CloseButton |
| /detail-header | DetailHeader |
| /dock-inset | DockInsetProvider |
| /elevated-button | ElevatedButton |
| /feedback-fab | FeedbackFAB |
| /glass | Glass |
| /item-row | ItemRow |
| /item-row-group | ItemRowGroup |
| /modal-presenter | ModalPresenter |
| /morphing-dock-switch | MorphingDockSwitch |
| /morphing-fab | MorphingFAB |
| /morphing-switcher | MorphingSwitcher |
| /primary-actions | PrimaryActions |
| /sortable-list | SortableList |
| /spotlight | Spotlight |
| /spotlight-target | SpotlightTarget |
| /stateful-button | StatefulButton |
| /surface | Surface |
| /switch | Switch |
| /checkbox | Checkbox |
| /checkbox-card | CheckboxCardGroup, CheckboxCard |
| /radio | Radio |
| /radio-card | RadioCardGroup, RadioCard |
| /star-rating | StarRating |
| /input | Input |
| /tabs | Tabs, TabsList, TabsTrigger, TabsContent |
| /breadcrumbs | Breadcrumbs |
| /card | Card |
| /text | Text |
| /skeleton | Skeleton |
| /loader | Loader |
| /animated-badge | AnimatedBadge |
| /text-cascade | TextCascade |
| /text-number-ticker | TextNumberTicker |
| /text-reveal | TextReveal |
| /text-rolling | TextRolling |
| /text-shimmer | TextShimmer |
| /scroll-reveal | ScrollReveal |
| /scroll-progress | ScrollProgress |
| /scroll-to | ScrollTo |
| /smooth-scroll | SmoothScroll |
| /dock | Dock |
| /reorderable-list | ReorderableList — drag-to-reorder list built on the gesture primitives |
| /draggable | Draggable |
| /hold-draggable | HoldDraggable — hold + drag in one gesture, with phase render-prop |
| /holdable | Holdable — hold-only, four-phase timeline, no drag |
| /dragzone | Dragzone |
| /drag-manager | DragManager |
| /multi-drag-manager | MultiDragManager |
| /multi-draggable | MultiDraggable |
| /multi-drag-scope | useMultiDragScope, useIsLifting |
| /multi-drag | readMultiDragIds, MULTI_DRAG_IDS_MIME |
| /drag-types | drag system type definitions |
| /drag-store | the drag registry, for custom sources and targets |
| /choice-group | ChoiceGroup |
| /toaster | Toaster, toast |
| /toggle-group | ToggleGroup |
| /use-drag-store | useActiveDrag, useDragSnapshot, useDragzoneState, useDragMove |
| /use-draggable | useDraggable — a drag with no markup attached |
| /drag-behavior | resolveDragBehavior, DRAG_TUNING_DEFAULTS, the per-OS press timeline |
| /use-drag-behavior | useDragBehavior — that timeline resolved for the running platform |
| /portal | Portal, PortalHost, PortalProvider, usePortalAvailable |
| /popover | Popover |
| /drawer | Drawer |
| /bottom-sheet | BottomSheet |
| /full-sheet | FullSheet |
| /morphing-modal | MorphingModal |
| /adaptive-modal | AdaptiveModal |
| /action-feedback-modal | ActionFeedbackModal |
| /adaptive-dropdown | AdaptiveDropdown |
| /menu | Menu, MenuSeparator, MenuLabel |
| /menu-item | MenuItem |
| /multi-step-menu | MultiStepMenu |
| /morphing-menu | MorphingMenu |
| /hover-menu | HoverMenu |
| /hold-menu | HoldMenuProvider, HoldItem, HoldMenuIcon |
| /overflow-actions | OverflowActions |
| /bouncy-accordion | BouncyAccordion |
| /activity-island | ActivityIsland, ActivityIslandState |
| /dynamic-island | DynamicIsland |
| /animated-list | AnimatedList, AnimatedListItem |
| /swipeable-list | SwipeableList |
| /range-slider | RangeSlider |
| /color-picker | ColorPicker |
| /cylinder-carousel | CylinderCarousel |
| /wheel-picker | WheelPicker |
| /command-palette | CommandPalette |
| /otp-input | OtpInput |
| /feedback-widget | FeedbackWidget |
| /table | Table |
| /table-header | HeaderCell |
| /table-row | TableRow, RowCell |
| /table-types | table type definitions |
| /table-utils | sorting / filtering helpers |
| /use-table | useTable |
| /use-column-reorder | useColumnReorder |
| /file-system | FileSystem |
| /file-icon | FileTypeIcon, FileSystemFolderGlyph |
| /icon | ThemedIcon |
| /icon-button | IconButton |
| /ease | easing constants |
| /tokens.css | design token stylesheet |
| /theme/use-theme-color | useThemeColor, useThemeColors |
| /breakpoints | breakpoint scale + pure resolvers |
| /hooks/use-breakpoint | useBreakpoint, useBreakpointAtLeast |
| /hooks/use-calendar | useCalendar |
| /hooks/use-date-picker | useDatePicker |
| /hooks/use-date-range-picker | useDateRangePicker |
| /calendar | ISO date arithmetic + month grids |
| /calendar-format | Intl label formatters |
| /calendar-props | calendar prop types + builders |
| /calendar-selection | selection, per-cell state, focus movement |
| /date-field | text ⇄ date parsing for the pickers' fields |
| /date-picker-props | trigger / panel / field prop types |
Responsive breakpoints
Every responsive decision in the library resolves against one scale, which
mirrors Tailwind's default screens so a md: class and a
useBreakpoint() === 'md' check flip at the same width:
| Name | Min width |
| ------ | --------- |
| base | 0 |
| sm | 640 |
| md | 768 |
| lg | 1024 |
| xl | 1280 |
| 2xl | 1536 |
useBreakpoint
Returns the current tier and re-renders only when that tier changes.
useWindowDimensions pushes a render for every resize frame — dragging a window
edge across 200 px re-renders the whole subtree ~200 times even though the
layout decision never moved. This subscribes to the same Dimensions event but
stores only the resolved breakpoint.
import {
useBreakpoint,
useBreakpointAtLeast,
} from "rn-motion-ui/hooks/use-breakpoint";
const breakpoint = useBreakpoint(); // 'base' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
const breakpoint = useBreakpoint({ md: 720 }); // move just the md edge
const isWide = useBreakpointAtLeast("md"); // one cutoff, boolean, flips once
const isWide = useBreakpointAtLeast(900); // raw pixels work tooOverrides are matched by value, not object identity, so an inline
{{ md: 720 }} literal doesn't re-subscribe on every render.
Per-component overrides
Components that pick a layout from the window width take a wideBreakpoint prop
— a breakpoint name or a raw pixel number:
| Component | Prop | Default |
| ------------------ | ---------------- | ------------ |
| AdaptiveModal | wideBreakpoint | 'sm' (640) |
| FullSheet | wideBreakpoint | 'sm' (640) |
| AdaptiveDropdown | wideBreakpoint | 'md' (768) |
<AdaptiveModal wideBreakpoint="md" … /> {/* stay narrow until 768 */}
<AdaptiveDropdown wideBreakpoint={900} … />AdaptiveModal also keeps isWideScreen to bypass width entirely, and
MultiStepMenu still receives isWideScreen from its caller.
FileSystem is the exception: it adapts to its own measured width, not the
window's, so it collapses inside a narrow parent too. Its tiers are container
widths and are overridden as a group:
<FileSystem breakpoints={{ minimal: 320, compact: 520, tablet: 720 }} … />Measuring your own container
The resolvers are pure and React-free, so a component that measures itself can reuse the same thresholds:
import { breakpointForWidth, isWidthAtLeast } from "rn-motion-ui/breakpoints";
const tier = breakpointForWidth(measuredWidth); // 'md'
const isWide = isWidthAtLeast(measuredWidth, "lg"); // booleanHeadless calendar and date pickers
Three hooks — useCalendar, useDatePicker, useDateRangePicker — that own
the date logic, the keyboard, and the accessibility payload, and render nothing.
There is no styled <Calendar /> in the package: the markup is yours, so the
grid can be seven Pressables in a View, a FlatList, or a table, without
fighting a wrapper's opinions.
Dates are ISO 'YYYY-MM-DD' strings throughout — arguments, return values, and
callbacks. No Date objects cross the API, so a value can be compared with
===, sorted as a string, stored as JSON, and put in a dependency array without
a stable-reference dance.
useCalendar
The month grid. State plus a prop getter per element, each returning an object to spread:
import { useCalendar } from "rn-motion-ui/hooks/use-calendar";
const calendar = useCalendar({
mode: "range",
numberOfMonths: 2,
minDate: "2026-01-01",
});
<View {...calendar.getRootProps()}>
<Pressable {...calendar.getPreviousMonthProps()}>
<Text>‹</Text>
</Pressable>
{calendar.months.map((month) => (
<View key={month.month} {...calendar.getMonthProps(month.month)}>
<Text {...calendar.getMonthLabelProps(month.month)}>{month.label}</Text>
<View {...calendar.getWeekdayRowProps(month.month)}>
{calendar.weekdays.map((weekday) => (
<Text
key={weekday.weekday}
{...calendar.getWeekdayProps(weekday, month.month)}
>
{weekday.short}
</Text>
))}
</View>
<View {...calendar.getGridProps(month.month)}>
{month.weeks.map((week, index) => (
<View key={index} {...calendar.getWeekProps(month.month, index)}>
{week.map((day) => (
<Pressable key={day.date} {...calendar.getDayProps(day)}>
<Text>{day.day}</Text>
</Pressable>
))}
</View>
))}
</View>
</View>
))}
</View>;Every cell arrives decorated — isSelected, isToday, isInRange,
isRangeStart, isRangeEnd, isPreview, isDisabled, isWeekend, and
outside for an adjacent-month day — so styling is a lookup rather than a
recomputation per render.
What the getters carry beyond the obvious:
- Roving tab stop. Exactly one cell per calendar has
tabIndex: 0; the rest are-1. Tab reaches the grid once and the arrow keys move within it, which is the WAI-ARIA grid pattern — 42 cells in the tab order is not. - Keyboard. Arrows step a day or a week,
Home/Endgo to the ends of the week,PageUp/PageDownstep a month (a year with shift). A step that leaves the visible months pages the view.isRTLmirrors the horizontal axis only — up is still up.preventDefaultis called only for keys the grid acts on, so Tab still leaves. - Focus. Moving the cursor focuses the destination cell through its
ref, including when that cell mounts after the step (paging to a month that isn't rendered yet). - Disabled days keep their tab stop. They get
aria-disabledandaccessibilityState, but notdisabled— an unreachable day cannot announce why it is unavailable. The press handler refuses. - Both a11y dialects. Native
accessibilityState/accessibilityRoleand webaria-*are emitted together, because react-native-web maps only thearia-form.
useDatePicker / useDateRangePicker
The grid plus a disclosure, a typeable text field per date, and a backdrop:
import { useDatePicker } from 'rn-motion-ui/hooks/use-date-picker';
const picker = useDatePicker({ onSelectDate: setValue, testID: 'depart' });
<TextInput {...picker.getFieldProps()} />
<Pressable {...picker.getTriggerProps()}><Text>Pick a date</Text></Pressable>
{picker.isOpen ? (
<>
<Pressable {...picker.getDismissProps()} />
<View {...picker.getPanelProps()}>
{/* render picker.calendar exactly as above */}
<Pressable {...picker.getClearProps()}><Text>Clear</Text></Pressable>
</View>
</>
) : null}The field is forgiving in the ways a date field has to be. Typing shows a draft
without committing it; blur commits, submit commits and closes. Text that
doesn't parse snaps back to the current value rather than silently discarding
it, and a complete, allowed date moves the grid as you type so the two never
disagree. format takes a { parse, format } pair for a non-ISO field order.
The range picker differs where a range genuinely differs: two months by default, two independent field drafts, and it closes when the range is complete — not on the first press, which only starts it. A range typed backwards is reordered rather than rejected, clearing one field leaves a half-open range the next press can complete, and a date typed in the end field is revealed in the last month on screen so the start stays visible beside it.
The trigger is a button with aria-expanded, deliberately not a combobox:
React Native has neither aria-controls nor aria-haspopup, so a combobox
would announce a popup assistive tech cannot then find. The panel is a dialog
whose three modal flags (role, aria-modal, accessibilityViewIsModal) all
follow one modal option, so an inline calendar never claims to trap focus that
nothing has trapped.
Test IDs
Pass testID and every child derives its own; pass nothing and no testID is
emitted anywhere, so a tree stays clean by default.
useDatePicker({ testID: "depart" });
// depart-trigger · depart-panel · depart-field · depart-clear · depart-dismiss
// depart-day-2026-08-05 · depart-grid-2026-08 · depart-month-label-2026-08 · depart-prev-monthPure helpers
The logic underneath is exported separately, all React-free:
rn-motion-ui/calendar (ISO arithmetic, buildMonthGrid, range helpers),
rn-motion-ui/calendar-format (Intl labels),
rn-motion-ui/calendar-selection (selection transitions, per-cell state, focus
movement), rn-motion-ui/calendar-props and rn-motion-ui/date-picker-props
(the prop shapes, for typing your own render functions), and
rn-motion-ui/date-field (parse/format and commit resolution).
Theming
Design tokens
The library ships a canonical Tailwind token sheet that you import once in your app's global CSS:
@import "tailwindcss";
@import "rn-motion-ui/tokens.css";This registers 13 semantic color tokens as Tailwind CSS utilities (bg-primary,
text-foreground, border-border, …) and applies automatic dark-mode overrides
via @media (prefers-color-scheme: dark).
Token reference
| Token | Light | Dark | Usage |
| ---------------------- | ----------------------- | ----------------------- | ---------------------------- |
| surface | oklch(99% 0 0) | oklch(9% 0 0) | Page / screen background |
| foreground | oklch(15% 0 0) | oklch(96% 0 0) | Default text |
| card | oklch(97% 0 0) | oklch(13% 0 0) | Card / panel surfaces |
| muted | oklch(97% 0 0) | oklch(16% 0 0) | Subtle backgrounds |
| muted-foreground | oklch(50% 0 0) | oklch(60% 0 0) | Secondary / placeholder text |
| border | oklch(15% 0 0 / 0.06) | oklch(99% 0 0 / 0.08) | Dividers and outlines |
| primary | oklch(15% 0 0) | oklch(96% 0 0) | Primary actions / fills |
| primary-foreground | oklch(99% 0 0) | oklch(15% 0 0) | Text on primary |
| secondary | oklch(97% 0 0) | oklch(18% 0 0) | Secondary fills |
| secondary-foreground | oklch(15% 0 0) | oklch(96% 0 0) | Text on secondary |
| danger | oklch(62% 0.22 25) | oklch(66% 0.22 25) | Error / danger states |
| success | oklch(70% 0.18 155) | oklch(72% 0.18 155) | Success states |
| warning | oklch(78% 0.18 75) | oklch(80% 0.18 75) | Warning states |
Overriding tokens
Add a @theme block after the import to override any token for your brand:
@import "rn-motion-ui/tokens.css";
@theme {
/* Brand blue as primary */
--color-primary: oklch(52% 0.22 250);
--color-primary-foreground: oklch(99% 0 0);
}For manual dark mode (.dark class on <html> instead of
prefers-color-scheme):
@import "rn-motion-ui/tokens.css";
/* tokens.css already handles system dark-mode; add .dark support too: */
@layer base {
.dark {
--color-primary: oklch(70% 0.22 250);
}
}Retinting the neutral palette
The neutral ramp (surfaces, foregrounds, borders, muted) is not pure grey —
every step carries a faint shared tint, declared in the sheet as --neutral-hue
/ --neutral-chroma. Those two are documentation, not inputs: uniwind folds
every color to a hex at bundle time, and a var() inside oklch() never folds
on native, so the tint has to be baked into the literals. To change it, generate
your own sheet:
npx rn-motion-ui-tokens --hue 250 --chroma 0.006That writes a tokens.css into the current directory. Import it instead of
rn-motion-ui/tokens.css — it's a full replacement, not an overlay:
@import "tailwindcss";
@import "./tokens.css";Every neutral-tinted literal is rewritten to the new hue, with each token's
chroma scaled proportionally so partial tints stay partial. Status colors
(danger, success, warning, info) and true achromatic values are left
alone, as are the shadow recipes, all three theme blocks, and anything you'd
added upstream. --chroma 0 drops the tint for a strictly neutral grey ramp.
Run it with no arguments and you get the shipped sheet back verbatim.
| Flag | Default |
| --------------- | ------------------------------------------------------------- |
| --hue <deg> | shipped --neutral-hue; cyclic, so -20 and 400 both work |
| --chroma <c> | shipped --neutral-chroma; 0–0.4 |
| --out <path> | ./tokens.css |
| --force, -f | refuse to overwrite unless passed |
| --stdout | write to stdout instead of a file |
Regenerate after upgrading rn-motion-ui so new tokens land in your copy.
Animated and SVG colors — useThemeColor
Reanimated worklets and react-native-svg props require resolved color strings,
not CSS utility classes. Use useThemeColor to read the current token value at
runtime:
import { useThemeColor } from "rn-motion-ui/theme/use-theme-color";
import { useAnimatedStyle } from "react-native-reanimated";
function Spinner() {
const color = useThemeColor("primary");
const style = useAnimatedStyle(() => ({ borderColor: color }));
return <Animated.View style={[styles.ring, style]} />;
}On web it reads the live CSS custom property (--color-primary) so consumer
@theme overrides are automatically respected. On native it uses
useColorScheme() to select from the static light/dark maps, matching the
values in tokens.css with no provider required.
Use useThemeColors() when you need several tokens at once:
import { useThemeColors } from "rn-motion-ui/theme/use-theme-color";
const { primary, danger, success } = useThemeColors();Menu motion
Every panel a trigger summons — AdaptiveDropdown (on wide screens),
HoverMenu, Popover — opens on one shared animation: a
fade up from 0.96 scale, sliding 8px toward its trigger, out of the corner
nearest it. They close the reverse way on a 200ms ease-in. Sheets are
deliberately not in this group; BottomSheet, FullSheet, Drawer and
AdaptiveModal slide from an edge, which is a different gesture with a
different feel.
All four take the same motion prop to retune it:
import { Popover, PopoverContent, PopoverTrigger } from "rn-motion-ui/popover";
<Popover
motion={{
enter: { type: "spring", stiffness: 140, damping: 22 },
offset: 16,
}}
>
<PopoverTrigger>…</PopoverTrigger>
<PopoverContent>…</PopoverContent>
</Popover>;| Key | Default | |
| -------- | ----------------------------------------------------------------- | --------------------------------------------------------------- |
| enter | MOTION_STANDARD — spring, 300 stiffness / 24 damping / 0.8 mass | merged over the default, so { stiffness: 140 } keeps the rest |
| exit | 200ms Easing.in(Easing.cubic) | same merge |
| scale | 0.96 | 1 opens with no scale |
| offset | 8 | px traveled toward the trigger; 0 opens with no slide |
useReducedMotion wins over all of it: whatever motion says, an OS
reduced-motion preference collapses these to a 150ms opacity fade with no scale
and no travel.
The pieces are exported from rn-motion-ui/theme/motion if you're building a
panel of your own that should match —
resolveMenuMotion({ motion, reduce, side }) returns the whole from /
animate / exit / transition / exitTransition set for a MotiView, and
menuTransformOrigin({ side, align }) gives the matching CSS
transform-origin.
License
MIT © Ivan Stepanian
