expo-animation

Par expo · skills

Framework (OSS). Construit des animations dans React Native et Expo, en prenant les décisions dans l'ordre qui détermine si elles semblent naturelles — faut-il animer, sur quel thread ça tourne, quelles propriétés, spring ou timing, comment le geste se transfert, comment ça se dégrade. Écrit l'implémentation avec Reanimated, Gesture Handler, Expo Router et expo-haptics. À utiliser pour animer quoi que ce soit dans une app Expo, ajouter des gestes, des sheets, des transitions d'écran, du feedback de pression ou des haptiques, ou corriger des animations qui saccadent sur l'appareil. Pour les animations web, utiliser `animate`.

npx skills add https://github.com/expo/skills --skill expo-animation

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 :

  1. Il n'y a pas de hover. Chaque affordance que le web met en hover doit vivre en press, position, ou ne pas exister.
  2. 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.
  3. 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 :

  1. Animer quelque chose qui ne devrait pas s'animer. La barrière ci-dessous existe pour produire zéro ligne de code parfois.
  2. Animer le bon truc sur le mauvais thread — un setState par frame, un PanResponder, une height animé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

  1. Exécute la séquence dans l'ordre. Les étapes 1 et 2 gatekeepent tout.
  2. Reanimated, pas le Animated core. Le Animated core ne peut pas être piloté par un geste sans franchir le bridge, et useNativeDriver refuse 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é.
  3. Pas de valeurs approximées. Les courbes et configs de spring viennent des tableaux ci-dessous.
  4. Reduced motion s'embarque avec l'animation, pas comme un suivi.
  5. 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

  • transform et opacity sont gratuits. Tout le reste est un layout pass. width, height, margin, padding, flex, top, left, gap relancent 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 width préserve le corner radius que scaleX détruirait.
  • Jamais scale(0). Commence à scale(0.9–0.97) + opacity: 0. Rien dans le monde réel n'apparaît de rien.
  • transform est 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'un BlurView statique à la place.
  • Les pourcentages marchent dans translate et 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 setState depuis 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'onUpdate ou un scroll handler. scheduleOnRN(fn, ...args) from react-native-worklets — le replacement Reanimated 4 du deprecated runOnJS(fn)(...args) — enfile un appel RN-runtime, et dans onUpdate c'est 60–120× par seconde. Ça appartient à onEnd, ou dans un useAnimatedReaction qui 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 .value est la forme que le React Compiler ne peut pas voir à travers — les docs Reanimated appellent get/set la façon compiler-safe. set prend 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.97 en 100–150ms sur n'importe quel pressable, Pressable + une CSS transition. scale prend 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.
  • pressRetentionOffset pour 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-expo configure le worklets Babel plugin automatiquement — pas d'étape babel.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 lance Failed to create a worklet à l'exécution.
  • GestureHandlerRootView doit 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.

Skills similaires