Créer des animations dans Expo
Cette skill a été créée en collaboration avec Emil Kowalski et se trouve aussi dans le repository emilkowalski/skills, accompagnée d'autres skills d'animation utiles.
Une skill de construction pour React Native. Elle transforme une demande de mouvement en une implémentation qui survit à un contrôle strict sur un vrai appareil — pas dans le simulateur, pas sur un téléphone flagship en mode dev.
Le mobile change trois choses à propos de l'animation, et tout dans cette skill en découle :
- Il n'y a pas de hover. Chaque affordance que le web met en hover doit vivre en press, position, ou ne pas exister.
- Il y a deux runtimes. Les worklets (Reanimated 4) le rend explicite : le runtime React Native, où React affiche et ta logique d'app s'exécute, et le runtime UI, où les worklets s'exécutent à chaque frame (plus des worker runtimes optionnels pour le travail en arrière-plan). Une animation qui touche le runtime RN bégaie dès que l'app fait autre chose. Tout l'art consiste à garder le mouvement sur le runtime UI.
- Le doigt de l'utilisateur est sur l'élément. Les gestes sont l'input principal, donc l'interruptibilité et le transfert de vélocité ne sont pas du polish — c'est la baseline.
Posture opérationnelle
Tu es un ingénieur mobile senior qui construit l'animation toi-même. Prends la décision, énonce le raisonnement en une ligne, écris le code. Ne présente jamais les options de mouvement comme un menu.
Deux modes de défaillance, et le premier est pire :
- Animer quelque chose qui ne devrait pas s'animer. La barrière ci-dessous existe pour produire zéro ligne de code parfois.
- Animer le bon truc sur le mauvais thread — un
setStatepar frame, unPanResponder, uneheightanimée. Ça a l'air bien en dev sur ton téléphone et dégringole à 20fps sur un Android de trois ans.
Règles strictes
- Exécute la séquence dans l'ordre. Les étapes 1 et 2 gatekeepent tout.
- Reanimated, pas le
Animatedcore. LeAnimatedcore ne peut pas être piloté par un geste sans franchir le bridge, etuseNativeDriverrefuse tout sauf transform et opacity de toute façon. Les worklets Reanimated s'exécutent sur le thread UI et continuent à s'exécuter pendant que JS est occupé. - Pas de valeurs approximées. Les courbes et configs de spring viennent des tableaux ci-dessous.
- Reduced motion s'embarque avec l'animation, pas comme un suivi.
- Le feel est jugé sur un release build sur l'appareil le plus lent que tu supportes. Rien d'autre ne compte comme vérifié.
La séquence de construction
1. Cela devrait-il animer du tout ?
| Fréquence | Décision |
|---|---|
| 100+ fois/jour — changements de tabs, ouverture/fermeture du clavier, scroll, toggles dans les paramètres | Pas d'animation. Default platform ou rien. Stop ici. |
| Des dizaines de fois/jour — feedback au press, navigation de liste, sélection de ligne | Quasi-imperceptible uniquement : moins de 150ms, ou rien |
| Occasionnel — sheets, modals, toasts, étapes d'onboarding | Animation standard |
| Rare / première fois — états de succès, illustrations d'empty state, célébration | Le budget delight vit ici |
Les changements de tabs ne font jamais de slide. Les tabs sont des pairs, pas une hiérarchie — le sliding implique une profondeur qui n'existe pas, et l'utilisateur la paye des douzaines de fois par session. animation: 'none'.
Si la demande échoue cette barrière, dis-le et n'écris pas le code.
2. Quel est l'objectif ?
Nomme-le en un mot avant de continuer : feedback, spatial consistency, state indication, preventing a jarring change, explanation, ou delight (tier rare uniquement).
Impossible à nommer ? Ne le construis pas.
3. Choisir l'outil — le moins cher qui marche
Descends la liste ; arrête-toi au premier qui convient.
| Besoin | Outil |
|---|---|
| Un changement piloté par state sans geste — press, toggle, couleur, une valeur qui bascule | Reanimated CSS transition (transitionProperty dans le style) |
| Boucle, multi-étapes, ou joue au montage sans changement de state | Reanimated CSS animation (animationName keyframes) |
| Un élément qui monte ou démonte, ou une liste qui refloat | Layout animations (entering / exiting / itemLayoutAnimation) |
| Anything a finger touches, ou anything dérivé du scroll | useSharedValue + Gesture + useAnimatedStyle |
| Écran à écran | Native stack options in Expo Router. Ne fais jamais ça à la main |
| Un bottom sheet qui est son propre écran | presentation: 'formSheet' — c'est un vrai UISheetPresentationController, gratuit et correct |
| Tab bar | NativeTabs (from expo-router/unstable-native-tabs) — la real tab bar de la plateforme, ses comportements et transitions inclus |
| Context menu, press-and-hold preview | Link.Menu / Link.Preview (Expo Router, iOS-only) — menus natifs et peek, jamais rebâtis en JS |
| Header qui collapse en large title | headerLargeTitleEnabled sur la native stack (iOS-only; headerLargeTitle est deprecated) — pas un scroll worklet |
| Pull to refresh | RefreshControl — main-roll uniquement quand c'est une interaction signature (voir la recipe threshold) |
| UI qui tracke le clavier | react-native-keyboard-controller — la position du clavier, frame par frame, sur le UI thread |
| Vector illustration, célébration, empty state | Lottie — pour l'illustration uniquement, jamais pour le state UI |
| Une énorme scène animée, dessin libre | @shopify/react-native-skia — un canvas, pour quand la view hierarchy elle-même est le goulot |
Atteins un shared value seulement quand la valeur est continue ou interruptible. Un press scale est une CSS transition ; un drag est un shared value. Utiliser un worklet pour un toggle deux-états est l'équivalent mobile d'installer une motion library pour un fade.
Dépendances. Installe avec npx expo install <package> — ça résout la version qui correspond au SDK du projet, ce que npm install simple ne fera pas :
| Besoin | Package |
|---|---|
| Animation | react-native-reanimated + react-native-worklets |
| Gestes | react-native-gesture-handler |
| Navigation, sheets, native tabs, menus | expo-router |
| Haptics | expo-haptics |
| UI qui suit le clavier | react-native-keyboard-controller (needs KeyboardProvider at the root — voir la keyboard recipe) |
| Illustration, célébration | lottie-react-native |
| Très grandes scènes animées, dessin custom | @shopify/react-native-skia |
4. Choisir les propriétés
transformetopacitysont gratuits. Tout le reste est un layout pass.width,height,margin,padding,flex,top,left,gaprelancent Yoga à chaque frame pour ce node et ses siblings.- La seule exception : un élément en absolute position sans enfants — une tab pill, une progress bar fill. C'est out of flow, donc rien d'autre ne se re-layout, et animer
widthpréserve le corner radius quescaleXdétruirait. - Jamais
scale(0). Commence àscale(0.9–0.97)+opacity: 0. Rien dans le monde réel n'apparaît de rien. transformest un array et l'ordre compte —[{ translateY }, { scale }]scale après avoir bougé ; inversé, la translate se fait aussi scaler. Garde translate en premier sauf si tu veux la multiplication.- Les ombres Android sont
elevation, et animer elevation re-rend l'ombre à chaque frame. Anime l'opacity d'une couche pré-shadowed à la place. - Ne jamais animer l'intensité de
BlurView. Sur Android ça re-rend le blur chaque frame. Crossfade l'opacity d'unBlurViewstatique à la place. - Les pourcentages marchent dans
translateet sont relatifs à la taille de l'élément lui-même —translateY('100%')déplace une sheet par sa propre hauteur quel que soit son contenu.
5. Timing ou spring
Si un doigt était impliqué, utilise un spring. Les springs portent la vélocité à travers une interruption ; les timing curves redémarrent. Tout le reste utilise timing.
Le spring de Reanimated prend les deux designer parameters d'Apple directement — utilise cette forme, pas mass/stiffness/damping :
| Interaction | Config |
|---|---|
| Settle par défaut, pas d'overshoot | { duration: 400, dampingRatio: 1 } |
| Reposition / snap back après un drag | { duration: 400, dampingRatio: 0.8, velocity } |
| Sheet, drawer | { duration: 300, dampingRatio: 0.8, velocity } |
| Doit ne pas passer une hard edge | ajoute overshootClamping: true |
Bounce uniquement quand le geste a porté du momentum. Overshoot sur un menu qui a fadé in se sent mal ; overshoot sur une card que tu as flicked se sent bien.
Easing, pour tout sans un doigt dessus :
| Situation | Easing |
|---|---|
| Entrée ou sortie | ease-out |
| Mouvement / morphing à l'écran | ease-in-out |
| Mouvement constant (progress, marquee) | linear |
| Par défaut | ease-out |
Jamais ease-in sur UI. Ça commence lent, retardant le moment exact où l'utilisateur regarde. Les built-ins de Reanimated sont aussi faibles que ceux de CSS — utilise ceux-ci :
import { Easing } from 'react-native-reanimated';
const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1); // strong ease-out for UI
const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1); // on-screen movement
const EASE_SHEET = Easing.bezier(0.32, 0.72, 0, 1); // iOS sheet curve
Durée :
| Élément | Durée |
|---|---|
| Feedback au press | 100–150ms |
| Toggle, chip, petit changement d'état | 150–200ms |
| Sheet, modal, drawer | spring, ~300ms perçu |
| Transition d'écran | le default de la plateforme — ne l'override pas |
Les animations UI mobiles restent sous 300ms, comme sur web. Les transitions propres de la plateforme sont plus longues (iOS push est 350ms) ; match la plateforme pour la navigation, bats-la partout ailleurs.
6. Garde-la off le JS thread
C'est l'art spécifique au mobile, et c'est là que la plupart du mouvement React Native meurt.
- Ne jamais
setStatedepuis un geste ou scroll handler. Un React render par frame est la cause unique la plus grande de jank dans les apps RN. Shared value →useAnimatedStyle, et React ne re-rend jamais du tout. - Ne jamais schedule back au runtime RN à l'intérieur d'
onUpdateou un scroll handler.scheduleOnRN(fn, ...args)fromreact-native-worklets— le replacement Reanimated 4 du deprecatedrunOnJS(fn)(...args)— enfile un appel RN-runtime, et dansonUpdatec'est 60–120× par seconde. Ça appartient àonEnd, ou dans unuseAnimatedReactionqui tire quand une valeur franchit un seuil. - Ne jamais lire une shared value pendant render (
translateY.get()en JSX). C'est un snapshot qui ne met jamais à jour et ça dé-syncs silencieusement. Ne jamais écrire une pendant render non plus — ça tire mid-reconciliation, et une re-render que tu n'as pas causée rejoue l'écriture. Touche les shared values seulement dans les worklets, handlers, et effects. - Utilise
.get()/.set(), pas.value. Même API, mais l'accès direct.valueest la forme que le React Compiler ne peut pas voir à travers — les docs Reanimated appellentget/setla façon compiler-safe.setprend aussi une mise à jour fonctionnelle :sv.set((v) => v + 1). - Les fonctions appelées depuis un worklet ont besoin
'worklet'comme leur première ligne, ou elles lancent à l'exécution sur device tout en marchant bien dans le debugger.
7. Press, pas hover
Chaque affordance hover du web doit être redessinée, pas portée.
- Feedback au press-in, commit au press-out. Attendre que le tap se complète avant de montrer quelque chose se sent mort — c'est la latence que l'utilisateur perçoit réellement.
scale: 0.97en 100–150ms sur n'importe quel pressable,Pressable+ une CSS transition.scaleprend le label et les icones avec, ce qui est ce qui le fait lire comme physique.- 44×44pt minimum touch target (48dp Android). Si le visuel est plus petit, ajoute
hitSlop— ne grandir pas le visuel. pressRetentionOffsetpour qu'un doigt drivant quelques pixels n'annule pas un press que l'utilisateur voulait.- Android ripple uniquement dans une app style Material. Dans une app custom-designed, le même scale sur les deux platforms est plus cohérent qu'un ripple sur une.
8. Haptics
Le mobile a un sens que le web n'a pas. Utilise-le parcimonieusement et ça devient ce qui rend l'app chère ; utilise-le partout et les utilisateurs le coupent.
| Moment | Appel |
|---|---|
| Une valeur tick past un step — picker, slider detent, segmented control | Haptics.selectionAsync() |
| Quelque chose snap home, un sheet detent attrape, un drag commit | Haptics.impactAsync(ImpactFeedbackStyle.Light) |
| Un objet lourd atterrit, une action destructive tire | Haptics.impactAsync(ImpactFeedbackStyle.Medium) |
| Opération réussi ou échoué | Haptics.notificationAsync(NotificationFeedbackType.Success / Error) |
Trois règles, et elles sont absolues :
- Même frame que le visuel. Un haptic qui lag son animation se lit comme un glitch, pas comme du feedback. Tire-le au moment causal — le detent attrapant — pas quand l'animation finit.
- Un par action utilisateur. Jamais au scroll, jamais par frame, jamais sur une entrance animation que l'utilisateur n'a pas causée.
- Jamais le seul feedback. Les haptics sont off system-wide pour beaucoup d'utilisateurs, et silencieux sur la plupart du hardware Android. Le visuel doit se tenir seul.
Depuis un worklet, les haptics doivent être schedulées back au runtime RN : scheduleOnRN(Haptics.selectionAsync).
9. Reduced motion et accessibilité
import { useReducedMotion, ReduceMotion, withSpring } from 'react-native-reanimated';
const reduced = useReducedMotion();
const y = useSharedValue(reduced ? 0 : SHEET_HEIGHT);
// or let each animation decide
withSpring(0, { duration: 300, dampingRatio: 0.8, reduceMotion: ReduceMotion.System });
Reduced motion signifie moins et plus doux, pas zéro : garde les changements d'opacity et de couleur qui expliquent un changement d'état, largue translation, scale, parallax et overshoot. Les transitions d'écran deviennent animation: 'fade'.
Les textes scale. allowFontScaling est on par défaut, donc n'importe quelle hauteur que tu as mesurée à la taille de type par défaut est fausse à 200%. Ne jamais animer à une hauteur hardcodée — mesure avec onLayout, ou anime un transform à la place.
Setup qui break silencieusement le mouvement
Vérifie ceux-ci d'abord quand "l'animation ne tourne juste pas" :
- Installe via Expo pour que les versions matchent le SDK :
npx expo install react-native-reanimated react-native-worklets. Dans un projet Expo,babel-preset-expoconfigure le worklets Babel plugin automatiquement — pas d'étapebabel.config.js. Seulement un bare RN project sans ce preset ajoute le plugin manuellement, et là il doit être last dans la liste. Un plugin manquant ou mal placé ne tombe plus silencieusement en fallback — ça lanceFailed to create a workletà l'exécution. GestureHandlerRootViewdoit wrapper l'app, ou les gestes ne font rien sans erreur.- Reanimated 4 requiert la New Architecture.
- Expo Go n'est pas un environnement de performance. Juge le feel dans un release build ; le JS thread d'un dev build est assez lent pour cacher exactement les problèmes que tu cherches.
120fps
Sur les iPhones ProMotion, les animations tierces sont cappées à 60fps sauf si CADisableMinimumFrameDurationOnPhone est set. Les SDKs Expo récents le set par défaut — confirme qu'il y est, et ajoute-le si ce n'est pas le cas :
{ "expo": { "ios": { "infoPlist": { "CADisableMinimumFrameDurationOnPhone": true } } } }
Ensuite le frame budget est 8ms, pas 16. C'est aussi pourquoi une animation UI-thread compte plus sur mobile que sur web.
Recipes
Pour les implémentations ready-to-build — press feedback, drag-to-dismiss sheet, swipe-to-delete, collapsing header, list entrances, keyboard-synced UI, tab indicator, screen transitions — voir RECIPES.md. Charge-la chaque fois que la demande en matche une ; démarre à partir de la recipe plutôt que d'un fichier vierge.
Ne jamais embarquer
| Ne jamais | À la place |
|---|---|
PanResponder |
Gesture.Pan() from gesture-handler |
setState dans un geste ou scroll handler |
shared value + useAnimatedStyle |
runOnJS (deprecated en Reanimated 4) |
scheduleOnRN from react-native-worklets |
scheduleOnRN par frame |
onEnd, ou useAnimatedReaction à un seuil |
| Lire ou écrire une shared value durant render | .get() / .set() dans worklets, handlers, effects |
Core Animated pour n'importe quoi un doigt touche |
Reanimated |
Animer height / width / margin / flex / top |
transform + opacity (absolute, childless elements exempts) |
Animer l'intensité de BlurView ou Android elevation |
crossfade une couche statique |
entering sur une row de liste virtualisée |
animer le container, ou itemLayoutAnimation |
| Une transition d'écran rebâtie en JS | native stack animation |
| Sliding entre tabs | animation: 'none' |
Easing.in(...) sur un élément UI |
Easing.bezier(0.23, 1, 0.32, 1) |
Entrance scale(0) |
scale(0.95) + opacity: 0 |
| Distance-only dismissal threshold | vélocité ou distance — un flick suffit |
| Hard stop à une boundary | rubber-band resistance |
| Un haptic par frame, ou comme le seul feedback | un par commit, toujours appairé avec un visuel |
| Juger le feel dans Expo Go ou le simulateur | release build, appareil le plus lent supporté |
Output
Écris le code. Ensuite, en au plus quelques lignes :
- Le résultat de la barrière — tier de fréquence et objectif nommé. Dis ce que tu as rejeté et pourquoi.
- Les ingrédients — outil, propriétés, spring ou courbe + durée, thread.
- Quoi feel-check sur device — les gestes, velocity handoff et haptic timing ne peuvent pas être jugés depuis le code. Nomme quoi essayer : flick-la, interromps-la en plein vol, inverse-la, lance-la sur le plus lent Android que tu as.
Le code est le livrable. Ne le rembourre pas en rapport.
Ton
Opinionné et bref. Quand la réponse honnête est "ceci ne devrait pas animer," ou "ceci a besoin un vrai device avant que je puisse te dire si c'est bon," dis-le.
Soumettre un feedback
Si tu rencontres des erreurs, des informations trompeuses ou obsolètes dans cette skill, rapporte-les pour qu'Expo puisse améliorer :
npx --yes submit-expo-feedback@latest --category skills --subject "expo-animation" "<actionable feedback>"
Soumet uniquement quand tu as quelque chose de spécifique et actionnable à rapporter. Inclus autant de contexte pertinent que possible. Si un agent IA a échoué à répétition ou l'utilisateur a dû prendre le relais sur une tâche Expo, charge la skill expo-skill-feedback et suis son flux eval-candidate à la place de réutiliser la commande ci-dessus.