alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
217 lines (164 loc) • 6.97 kB
Markdown
---
name: alouette-animation
description: >
Animate alouette UI two ways: NativeWind transitions (transition-*, duration-*,
ease-* on state changes like active:/hover:/focus:) and CSS keyframe presence
animations via PresenceOne / PresenceList (animate-slide-in/out,
animate-collapse-in/out). exitDurationMs must come from animationDurationsMs so
the unmount timer matches the keyframe. Both run on native via
react-native-reanimated. Load when adding transitions or enter/exit animations.
type: core
library: alouette
library_version: "22.0.0"
sources:
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Presence.tsx"
- "christophehurpeau/alouette:packages/alouette/src/animationDurationsMs.ts"
- "christophehurpeau/alouette:packages/alouette/src/ui/containers/Presence.stories.tsx"
---
# 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 (e.g. press scale plus background/border color) with the system's
duration and easing. Don't reach for a raw react-native `Pressable`.
```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. Prefer the
named `duration-*` tokens over raw numbers — they mirror `animationDurationsMs`
(`fast` 200, `fade` 300, `slide`/`progress` 600, `collapse` 800):
```tsx
<InteractiveBox className="transition-[background-color] duration-fast ease-in hover:bg-lowered" />
```
## Presence: swap a single child
```tsx
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.
```tsx
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`.
## Common Mistakes
### HIGH Hand-rolling transitions on a raw Pressable
Wrong:
```tsx
import { Pressable } from "react-native";
<Pressable className="transition-[transform] duration-200 active:scale-[0.975]">
{children}
</Pressable>
```
Correct:
```tsx
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/data/PressableBox.tsx
### HIGH Hardcoding exitDurationMs instead of animationDurationsMs
Wrong:
```tsx
<PresenceOne activeKey={id} exitDurationMs={600}
enterClassName="animate-slide-in" exitClassName="animate-slide-out">
```
Correct:
```tsx
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 PresenceList children without stable keys
Wrong:
```tsx
{items.map((it, i) => <InfoMessage key={i}>{it}</InfoMessage>)}
```
Correct:
```tsx
{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:
```tsx
<PresenceOne activeKey={id} exitDurationMs={animationDurationsMs.slide}>
<>{content}</>
</PresenceOne>
```
Correct:
```tsx
<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)