lovdacn@beta, which installs from the beta registry and may change before the stable release.What’s in betaShared animation engine — the animate / activeAnimate contract and Motion hosts for animating any component or custom content.
animate / activeAnimate API and the motion engine may change before the stable release. Install it with lovdacn@beta and re-check after upgrading. Feedback is welcome on GitHub.Motion is the shared animation engine behind lovdaCN's animate and activeAnimate
props. It runs on the UI thread with Reanimated, respects system reduced-motion, and
exposes typed object configuration plus standalone Motion hosts for your own content.
Beta. Object configuration (animate / activeAnimate) and the Motion hosts are
available now. Utility-string syntax (e.g. animate="fade-in") and automatic
per-component wiring across the whole registry are planned for later phases and may
change. Install the CLI from the beta dist-tag while this stabilizes.
Installation
npx lovdacn@beta add motionThis copies components/ui/motion.tsx and installs react-native-reanimated and
react-native-worklets. Follow Expo's Reanimated setup (Babel plugin) if your project
does not already have it.
How it works: the primitives seam
Components don't import their hosts from react-native directly. They import them from
components/ui/primitives:
import { Pressable, View, Text, TextInput } from '@/components/ui/primitives';That one file has two interchangeable implementations:
- plain (the default, installed with every component) — accepts
animate/activeAnimate, ignores them, renders the raw React Native host. No Reanimated, so an app that never adds motion ships no animation runtime. - motion-aware (installed by
lovdacn add motion) — renders engine-backed animated hosts.
Because every component renders through this seam, add motion swaps one file and the whole
library starts honouring animate. Nothing in the components changes, and adding a new
component costs no animation work.
lovdacn add button → button + primitives (plain) → no Reanimated
lovdacn add motion → motion.tsx + primitives (motion-aware) → animation live everywhereComponent defaults live in the components, not the hosts — Button passes
activeAnimate ?? 'press', so it has press feedback by default and you can override or
disable it.
Usage
Button (reference integration)
Button supports both props out of the box and ships with a subtle default press scale.
import { Button } from '@/components/ui/button';
// Default: subtle press feedback (scale 0.97, snappy spring).
<Button onPress={save}>Save</Button>
// Custom mount + press animation.
<Button
animate={{
initial: { opacity: 0, translateY: 8 },
to: { opacity: 1, translateY: 0 },
transition: { type: 'timing', duration: 180, easing: 'ease-out' },
}}
activeAnimate={{
to: { scale: 0.96 },
transition: { type: 'spring', damping: 14, stiffness: 240 },
}}
onPress={save}
>
Save
</Button>
// Disable only the default press feedback.
<Button activeAnimate={false} onPress={save}>No bounce</Button>Motion host (any content)
Wrap user-owned content with Motion (or the typed MotionView, MotionPressable,
MotionText, MotionTextInput hosts).
import { Motion } from '@/components/ui/motion';
<Motion
animate={{ initial: { opacity: 0, scale: 0.96 }, to: { opacity: 1, scale: 1 } }}
activeAnimate={{ scale: 0.98 }}
>
<CustomTile />
</Motion>Presets
Preset names resolve to the same object model.
<Motion animate="fade-in" />
<Motion animate="slide-up" />
<Button activeAnimate="press" />
<Motion animate="spin" /> {/* continuous; stops under reduced motion */}Utility strings
Strings are sugar that compile to the same object config — there is no second engine.
<Button animate="fade-in slide-up duration-200" activeAnimate="press:scale-95 spring-snappy" />
<Motion animate="scale-95 rotate-45 delay-100" />Supported tokens: presets (fade-in, slide-up, zoom-in, pop, spin, pulse, …),
transforms (scale-*, opacity-*, translate-x-*, translate-y-*, rotate-*), transitions
(duration-*, delay-*, ease-*, spring-soft|snappy|bouncy) and state prefixes
(press:, hover:, focus:, checked:, selected:, open:, expanded:). Unknown tokens
warn in development and are ignored.
asChild — animate someone else's element
Motion normally renders its own host. With asChild it merges the animated style, composed
handlers and ref into a single child instead, so no extra layout node is added.
<Motion asChild animate="fade-in">
<MyTile /> {/* must forward `ref` and accept `style` */}
</Motion>Staggering a list
import { Motion, stagger } from '@/components/ui/motion';
{items.map((item, i) => (
<Motion key={item.id} animate={stagger('slide-up', i, 60)}>
<Card>{item.title}</Card>
</Motion>
))}API
Every visual host accepts SharedAnimationProps:
| Prop | Type | Description |
|---|---|---|
animate | false | MotionPresetName | string | AnimateConfig | Idle / mount / loop animation. false disables it. |
activeAnimate | false | MotionPresetName | string | MotionTarget | ActiveAnimateConfig | Motion while the component's semantic active state is true. |
motionActive | boolean | Explicit active-state override for static hosts (Card, Text, …). |
reduceMotion | "system" | "always" | "never" | Reduced-motion policy. Defaults to system. |
Semantics
animatedefines the idle/lifecycle layer:initialis the first rendered state,tois the resting state.activeAnimateoverlays the idle target while the active state is on and returns to idle when it turns off.- State precedence:
disabled→dragging→press→ semantic (checked,selected,open,expanded,current,loading) →focus→hover→ idle. animate={false}andactiveAnimate={false}disable only their own layer.- User handlers (
onPressIn,onPressOut, focus, hover, …) are composed, not replaced — each of your callbacks still fires exactly once. Refs resolve to the underlying host.
Reduced motion
With reduceMotion="system" (the default), motion snaps to its final accessible value
instead of animating when the OS "Reduce Motion" setting is on, and continuous loops stop.
Essential state (checked indicators, open content, focus affordances) is always preserved.
Class-driven animation on web (animate-in, animate-pulse, CSS transitions) is owned by CSS,
not the engine, so lovdacn init also writes a @media (prefers-reduced-motion: reduce) block
into your global stylesheet that neutralises it. One owner per property, both systems covered.
Beta scope
- Available: object and utility-string
animate/activeAnimateon every component that renders its host through the seam,Motion(includingasChild),MotionView/MotionPressable/MotionText/MotionTextInput,useMotion,useMotionState,motionPresets, the shareddurations/transitionstokens,stagger(), and reduced motion on both the engine and CSS paths. - Known gaps:
Popover,TooltipandHoverCardcontent renders inside the primitive's own animated wrapper, so it keeps its built-in enter/exit animation and does not exposeanimateyet — wrap those in<Motion>if you need custom motion. The API may still change before the stable release.