Foundations
Motion & interaction
Motion is springs, not durations (Apple’s fluid interfaces, Material 3’s motion scheme). The same nine springs drive the web (CSS linear() easings) and React Native (Reanimated), so a sheet or a toast moves the same on every platform. Transitions stay interruptible: a new target retargets from where the element is.
The reduced-motion preview applies the CSS fallbacks (no travel or scale; crossfades stay). Components that check prefers-reduced-motion in JavaScript follow your OS setting.
Springs
Three families, three speeds. Curves are drawn from the shipped tokens; press a track to run one.
spatial
317 ms
effects
231 ms
expressive
435 ms
| Family | Use | Fast / default / slow |
|---|---|---|
spatial | Position and size: panels, sheets, indicators. Damping 0.9, a hair of settle. | 224 / 317 / 484 ms |
effects | Opacity and colour. Critically damped: never overshoots. | 150 / 231 / 327 ms |
expressive | Key moments only: toggles, success, drag release. Damping 0.7–0.8. | 364 / 435 / 600 ms |
transition: transform var(--motion-spring-spatial-default-duration)
var(--motion-spring-spatial-default-easing);
/* Tailwind: duration-spatial-default ease-spatial-default */Gallery: web
Every motion in @nexub/react, live. Replay, slow it down, or preview the reduced-motion fallback with the bar above.
Dialog
nx-motion nx-dialog · effects + spatial
Exit is faster, never overshoots
Popover
nx-motion nx-pop · grows from its trigger
Drawer
nx-drawer · spatial spring from its edge
Stagger
nx-stagger · 40 ms apart
Re-key the parent to replay
Payment #1
Payment #2
Payment #3
List add and remove
ListTransition · rows grow in, collapse out
Skeleton to content
SkeletonSwap · crossfade
Number ticker
NumberTicker · digits roll on the spatial spring
Sliding indicator
SegmentedControl · spatial spring
Press and lift
nx-press 0.97 · nx-lift 2px
Hover and press
Collapse animates height on the spatial spring.
Fade · Slide · Scale · Collapse
Motion primitives · on springs
On iOS & Android
@nexub/react-native uses the same springs through Reanimated. Toasts, banners, list rows, chips, the segmented control, sheets and press feedback already move; use the presets for your own views.
import Animated, { withSpring } from 'react-native-reanimated';
import { nxEnter, nxExit, nxLayout, nxSpring } from '@nexub/react-native';
// Enter and exit on the effects + spatial springs; off under Reduce Motion.
<Animated.View entering={nxEnter('up')} exiting={nxExit('down')} layout={nxLayout()} />
// Lists: rows glide into place when others are added or removed.
<Animated.FlatList itemLayoutAnimation={nxLayout()} … />
// Your own value, on a token spring.
offset.value = withSpring(target, nxSpring('spatial', 'fast'));
// Money: the new figure rises in when the value changes.
<Amount value={balance} currency="SGD" size="display" animate />| Preset | Kinds | Use |
|---|---|---|
| nxEnter(kind, speed) | 'fade' | 'up' | 'down' | 'scale' | Views appearing: toasts, banners, new rows. |
| nxExit(kind, speed, rtl) | 'fade' | 'up' | 'down' | 'scale' | 'start' | 'end' | Views leaving; exits are faster and never overshoot. |
| nxLayout(speed) | — | Siblings gliding into place after a change. |
| nxSpring(family, speed) | spatial | effects | expressive | withSpring on your own shared values. |
Interactive preview of @nexub/react-native rendered for the web. Native chrome (switches, pickers, Liquid Glass) looks like the platform on a device.
State layers
Hover, focus and press are an overlay of the control’s own text colour at a fixed opacity, so they read correctly on any fill, theme, brand or glass: --state-hover 0.08, --state-focus 0.10, --state-pressed 0.12, --state-dragged 0.16. Add nx-state-layer to any custom control. Pressables also scale to 0.97 on a fast spring (nx-press). There is no ripple. On mobile the pressed layer and the press spring come with a semantic haptic instead of hover.
Enter and exit
Overlays, menus, tooltips and toasts share one set of keyframes. Add nx-motion and the element fades in on the effects spring while it moves and scales in on the spatial spring. It runs on Radix’s data-state (open / closed), or on data-motion="from-…" / "to-…" for anything else. Exits are faster and never overshoot. nx-pop scales floating layers from 0.96 with a 4px nudge away from the side they open on; nx-dialog scales from 0.96 and rises 8px; nx-drawer slides from its edge.
<div className="nx-motion nx-pop" data-state={open ? 'open' : 'closed'} data-side="bottom">…</div>Lists, stagger and loading
ListTransition grows new items in and collapses removed ones in place (space items with padding, not gap). nx-stagger brings children in 40ms apart; re-key the parent to replay. SkeletonSwap crossfades a skeleton into the content, so loaded data never pops.
<ListTransition as="ul" itemAs="li">{items.map((i) => <Row key={i.id} />)}</ListTransition>
<div className="nx-stagger" key={run}>…</div>
<SkeletonSwap loading={isLoading} skeleton={<Skeleton shape="rect" height={120} />}>{content}</SkeletonSwap>Cards
Card variant="interactive" lifts 2px on hover on the spatial spring and moves from elevation 1 to 2; leaving settles faster on the effects spring. Press sinks it to 0.99. The lift only applies where a real pointer hovers, so touch never gets stuck in the hover state. effect="sheen" is opt-in: a soft light follows the pointer. Keep it to one or two hero cards per page.
Page transitions
Opt in with one import. The old page fades out fast; the new one fades in and rises 8px on the spatial spring. Single-page apps wrap the route update in startViewTransition; where the browser has no View Transitions API it just runs. On mobile the native stack does the push and the back gesture.
import '@nexub/themes/view-transitions'; // after the base theme
import { startViewTransition } from '@nexub/react';
startViewTransition(() => navigate('/settings'));Scroll
AppHeader condense shrinks the header from 64 to 52px and lifts it onto elevation 1 over the first 64px of page scroll, with a CSS scroll timeline and no JavaScript. On iOS the native large title does the same.
Focus and touch targets
One focus ring for everything: a 3px outline in --color-ring-default, offset 2px, following border-radius and using Highlight in forced-colours mode. On coarse pointers small controls get an invisible 44×44px hit area (nx-hit) without changing their size; native controls always reach 44pt.
Reduced motion
Transitions and animations become instant changes.
Overlays, revealed content and list items keep a short crossfade with no travel or scale, so nothing pops in.
Native presets, the press spring and sheets follow the OS setting automatically.
Rely on motion alone to convey meaning.
Animate backdrop-filter, or use transition-all.
Loop motion on content people read (shimmer and morph are for loading only).