---
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. Load when
  adding transitions or enter/exit animations.
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"
---

# 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. 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/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 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)
