alouette
alouette-animation
Animate alouette UI two ways: NativeWind transition utilities for a change of state (hover, focus, press), and PresenceOne / PresenceList for content entering and leaving, which keep an element mounted long enough to play its exit keyframes. Their exit timing is read from the library's animationDurationsMs rather than hardcoded, so it stays in step with the keyframes. Both run on native through react-native-reanimated, and both honour the OS reduced-motion setting on web and native; useReducedMotion covers motion driven from JS. Load when adding transitions or enter/exit animations, or handling prefers-reduced-motion.
alouette — Animation
alouette animates with plain CSS — no animation library in your code — and on native NativeWind v5 runs it through react-native-reanimated. There are two mechanisms:
- Transitions — transition-* + duration-* + ease-* smooth a property change driven by state (active:, hover:, focus:, theme/accent change).
- Presence (keyframes) — PresenceOne / PresenceList play enter/exit keyframe animations on mount/unmount (animate-slide-in/out, animate-collapse-in/out), with durations from animationDurationsMs.
Transitions (state changes)
State-change transitions (transition-* + duration-* + ease-*) are already built into alouette's interactive components. Use InteractiveBox (or PressableBox, Button, IconButton) — they animate press/hover/focus coherently with the system's duration and easing. Don't reach for a raw react-native Pressable.
The press is a rigid one-pixel drop (active:translate-y-px), and it is deliberately left out of the transition so it lands instantly while the ground fades. Don't restore a transition-transform, and don't reach for active:scale-*: a scale displaces every point in proportion to its distance from the center, so the same class that nudges a button squeezes a full-width row. A disabled box drops it, and withPressEffect={false} turns it off for a bare label row whose indicator takes the press through group-active: instead (Radio, Checkbox), so the text never moves.
Source: packages/alouette/src/ui/containers/Box.tsx
import { InteractiveBox, Text } from "alouette";
<InteractiveBox onPress={open} className="bg-surface rounded-sm p-m">
<Text>Animates on press / hover / focus</Text>
</InteractiveBox>;For a custom transition, add transition-* utilities on an alouette component that forwards className so it composes with the built-in ones. Use the named duration-* tokens, never raw numbers — they mirror animationDurationsMs (fast 200, fade 300, slide/progress 600, collapse 800), and only they collapse under reduced motion:
<InteractiveBox className="transition-[background-color] duration-fast ease-in hover:bg-lowered" />Presence: swap a single child
import { PresenceOne, Box, Text, animationDurationsMs } from "alouette";
function Swap({ step }: { step: number }) {
return (
<Box className="relative h-24 w-64">
<PresenceOne
activeKey={step}
exitDurationMs={animationDurationsMs.slide}
enterClassName="animate-slide-in"
exitClassName="animate-slide-out"
className="absolute inset-0 flex-center bg-surface rounded-md"
>
<Box>
<Text className="font-heading-bold text-2xl">Step {step}</Text>
</Box>
</PresenceOne>
</Box>
);
}Core Patterns
Animated list (add / remove)
Each child must have a stable key. Adding a key animates that item in; removing one animates only that item out before unmounting.
import { PresenceList, InfoMessage, animationDurationsMs } from "alouette";
<PresenceList
exitDurationMs={animationDurationsMs.collapse}
enterClassName="animate-collapse-in"
exitClassName="animate-collapse-out"
className="overflow-hidden"
>
{items.map((item) => (
<InfoMessage
key={item.id}
onDismiss={() => remove(item.id)}
dismissIconAriaLabel="Dismiss"
>
{item.label}
</InfoMessage>
))}
</PresenceList>;PresenceOne swaps a single child and merges the animation classes onto it via cloneElement (no wrapper); PresenceList wraps each child in its own View.
Reduced motion
When the user asks the OS to reduce motion, every alouette motion token goes to zero: the duration-* utilities, the default transition-* duration and the animate-* keyframes. On web a @media (prefers-reduced-motion: reduce) block in core.css does it. Native cannot evaluate that query, so AlouetteProvider reads AccessibilityInfo and pushes the same values through NativeWind's variable context. Nothing to wire beyond rendering inside AlouetteProvider. PresenceOne / PresenceList then drop their animation classes and swap or remove at once, Modal / Popover open without the fade.
Motion you drive from JS reads useReducedMotion():
import { useReducedMotion } from "alouette";
import { ReduceMotion, withTiming } from "react-native-reanimated";
const reducedMotion = useReducedMotion();
offset.value = withTiming(target, {
reduceMotion: reducedMotion ? ReduceMotion.Always : ReduceMotion.Never,
});Pass it explicitly rather than relying on Reanimated's ReduceMotion.System, which is read once at launch.
Source: packages/alouette/src/core/ReducedMotionContext.ts; src/core/AlouetteProvider.tsx; src/reducedMotionVariables.ts; ui/containers/Presence.tsx
Common Mistakes
HIGH Hand-rolling transitions on a raw Pressable
Wrong:
import { Pressable } from "react-native";
<Pressable className="transition-[transform] duration-200 active:scale-[0.975]">
{children}
</Pressable>;Correct:
import { InteractiveBox } from "alouette";
<InteractiveBox onPress={open}>{children}</InteractiveBox>;alouette's interactive components already bundle press/hover/focus transitions with consistent duration and easing, plus disabled and focus-visible handling. Re-implementing them on a bare Pressable drifts from the system and misses that behavior.
Source: packages/alouette/src/ui/containers/Box.tsx (InteractiveBox); ui/actions/PressableBox.tsx
HIGH Hardcoding exitDurationMs instead of animationDurationsMs
Wrong:
<PresenceOne activeKey={id} exitDurationMs={600}
enterClassName="animate-slide-in" exitClassName="animate-slide-out">Correct:
import { animationDurationsMs } from "alouette";
<PresenceOne activeKey={id} exitDurationMs={animationDurationsMs.slide}
enterClassName="animate-slide-in" exitClassName="animate-slide-out">The unmount timer must equal the CSS keyframe duration. A hardcoded number drifts from the framework value, cutting the exit animation short or leaving ghost nodes mounted.
Source: packages/alouette/src/animationDurationsMs.ts; ui/containers/Presence.stories.tsx
HIGH Gating motion with motion-reduce: / motion-safe: or a raw duration
Wrong:
<Box className="transition-transform duration-[250ms] motion-reduce:transition-none" />Correct:
<Box className="transition-transform duration-fast" />react-native-css never matches prefers-reduced-motion, so on native motion-reduce: never applies and motion-safe: switches the animation off for everyone. An arbitrary duration-[…] is not a token and keeps playing under reduced motion; the named duration-* and animate-* tokens collapse on both platforms.
Source: packages/alouette/src/reducedMotionVariables.ts; node_modules/react-native-css/src/native/conditions/media-query.ts
HIGH PresenceList children without stable keys
Wrong:
{
items.map((it, i) => <InfoMessage key={i}>{it}</InfoMessage>);
}Correct:
{
items.map((it) => <InfoMessage key={it.id}>{it.label}</InfoMessage>);
}PresenceList diffs children by key to play add/remove animations. Index keys make removed items jump or skip their exit animation.
Source: packages/alouette/src/ui/containers/Presence.tsx (usePresenceList)
MEDIUM PresenceOne child that doesn't forward className
Wrong:
<PresenceOne activeKey={id} exitDurationMs={animationDurationsMs.slide}>
<>{content}</>
</PresenceOne>Correct:
<PresenceOne
activeKey={id}
exitDurationMs={animationDurationsMs.slide}
enterClassName="animate-slide-in"
exitClassName="animate-slide-out"
>
<Box>{content}</Box>
</PresenceOne>PresenceOne merges the animation classes onto the child via cloneElement. A Fragment or a component that drops className gets no animation; use an alouette component (or one that forwards className to its root view).
Source: packages/alouette/src/ui/containers/Presence.tsx (PresenceOne)
HIGH Expecting animations to run on native without reanimated
Wrong: relying on the CSS animation while react-native-reanimated (and the react-native-worklets/plugin babel plugin) are not installed.
Correct: install react-native-reanimated and add the worklets babel plugin, so NativeWind can run CSS transitions/animations on device.
NativeWind v5 drives native animations through reanimated; without it, animations silently no-op on iOS/Android while still working on web.
Source: packages/storybook-native-app/babel.config.js; packages/alouette/package.json (peerDependencies)