UNPKG

alouette

Version:

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

237 lines (182 loc) 7.66 kB
--- 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)