---
name: alouette-animation
description: >
  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.
type: core
library: alouette
sources:
  - "christophehurpeau/alouette:packages/alouette/src/ui/containers/Box.tsx"
  - "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"
  - "christophehurpeau/alouette:packages/alouette/src/core/ReducedMotionContext.ts"
  - "christophehurpeau/alouette:packages/alouette/src/reducedMotionVariables.ts"
  - "christophehurpeau/alouette:packages/alouette/src/core/AlouetteProvider.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 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

```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:

```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`.

### 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()`:

```tsx
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:

```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/actions/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 Gating motion with motion-reduce: / motion-safe: or a raw duration

Wrong:

```tsx
<Box className="transition-transform duration-[250ms] motion-reduce:transition-none" />
```

Correct:

```tsx
<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:

```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)
