Animations

Swap out animation drivers per-platform or at runtime

Tamagui animates style props through swappable drivers, with the same component API across web and native.

Features

  • Animate any style prop with animation config per-prop.

  • Can animate across all states (media queries, hover, etc).

  • Multiple drivers you can swap out with type safety.

  • SSR safe mount animations.

  • Enter and exit animations with AnimatePresence.

Add animations to Tamagui with an animation driver. Animation drivers are designed to be swappable, so you can use lightweight CSS animations or web-focused libraries on the web, while using Reanimated on native, without changing component code outside of configuration.

For this guide, we’ll use the React Native driver as an example, but you can choose from several Animation Drivers. For mount and unmount animations, see AnimatePresence.

Installation

yarn add @tamagui/animations-react-native

Then add it to your Tamagui config:

import { createAnimations } from '@tamagui/animations-react-native'
import { createTamagui } from 'tamagui'
export default createTamagui({
animations: createAnimations({
bouncy: { duration: 400, bounce: 0.5 },
lazy: { duration: 500, bounce: -0.2 },
quick: { duration: 220, bounce: 0.3 },
}),
// ...
})

A config entry is { duration, bounce }, a CSS timing string, or { type: 'timing', duration, easing }. stiffness, damping, and mass still work for a config already tuned against them. The same config resolves to the same motion on all four drivers, so you can swap drivers without retuning.

@tamagui/config ships this table for every driver, so you only need createAnimations when you want names of your own:

import { animationsCSS } from '@tamagui/config/animations-css'

Usage

The transition prop accepts the name of an animation you’ve configured. By default, animations will apply to all animatable styles, similar to setting all in a CSS transition.

Use a hover object key or a hover: string clause to animate a state value:

<html.div transition="quick" scale="1 hover:1.1" />

Here is an interactive example:

The transition prop rules

If you add a transition prop, you must always keep the prop. If you need the animation to be disabled, pass false, null or even undefined if it suits you.

The spring-based animation drivers have expensive hooks that would degrade runtime performance if present on every component. As a workaround, the animation hooks are called conditionally based on whether the transition key is present in the props object.

So, <Square transition={isActive ? 'bouncy' : null} /> rather than <Square {...isActive && { transition: 'bouncy' }} />.

If you’d like to remove or add a transition prop after a component has already rendered, you’d have to change the key.

Enter clauses

Setting an enter object key or an enter: string clause tells a property to start at that value, then animate to its base value after mount:

<html.div transition="quick" opacity="1 enter:0" />

The transition value

transition takes a CSS transition string or an object. Both forms mean the same thing:

// a css transition, exactly as css writes it
<html.div transition="200ms" />
<html.div transition="200ms ease-out" />
<html.div transition="transform 200ms, opacity 100ms 50ms" />
// a configured preset name, anywhere a timing goes
<html.div transition="quick" />
<html.div transition="transform quick, opacity 100ms" />
// a spring, inline
<html.div transition="spring(400ms, 0.5)" />
// nothing transitions
<html.div transition="none" />

The object form takes the same values under names:

<html.div transition={{ preset: 'quick', delay: 100, properties: 'transform, opacity', // any key that is not a transition setting is a style property opacity: '150ms ease-out', }} />

The settings are preset, duration, bounce, easing, delay, behavior, properties, spring, enter, and exit. Every other key names a style property and gets its own timing. An unrecognized key is an error rather than a silent no-op.

Granular animations

A per-property key wins for that property, the same way a later entry wins in a CSS transition list. These two are the same transition:

<html.div transition="all 200ms, opacity 100ms" />
<html.div transition={{ duration: 200, opacity: 100 }} />

Per-property values take a preset name, a CSS timing, or an object:

<html.div
transition={{
preset: 'bouncy',
// a preset name
x: 'lazy',
// a css timing
opacity: '150ms ease-out',
// or its own settings, including the low-level spring escape hatch
y: { preset: 'bouncy', spring: { overshootClamping: true } },
}}
/>

Entries do not inherit from the base. { duration: 200, opacity: {} } gives opacity no timing of its own, the same as transition: all 200ms, opacity in CSS. Name what you want on each entry, or use preset for a shared starting point.

Springs

A spring is duration plus bounce:

<html.div transition={{ duration: 400, bounce: 0.5 }} />
<html.div transition="spring(400ms, 0.5)" />

duration is the spring’s period, which is what “how fast does this feel” means. It is not a stopwatch: a bouncy spring keeps ringing past it, which is the point of a bouncy spring. bounce is 0 for critically damped (fast, no overshoot), goes up toward 1 for loose and oscillating, and goes negative for sluggish. A value outside -1 to 1 is an error.

On the web this compiles to a linear() easing that traces the real spring curve, overshoot included, with no JavaScript running.

When you need the physics directly, spring is the escape hatch:

<html.div transition={{ spring: { stiffness: 250, damping: 20, mass: 1.2 } }} />

It takes stiffness, damping, mass, velocity, overshootClamping, restDisplacementThreshold, and restSpeedThreshold. Tamagui derives the duration/bounce pair from stiffness and mass, so the same motion reaches the CSS driver too. It is a projection of duration and bounce, not a second API.

Presets

A preset is a name from your config’s animations. Any name works where a timing goes, and preset in the object form takes overrides:

<html.div transition={{ preset: 'bouncy', duration: 250 }} />

Overriding duration or bounce re-solves the spring, so the preset keeps whichever of the two you did not name and drops the stiffness/damping it was written with, since those are what you just replaced. mass carries over: it describes the object rather than the curve. Naming stiffness or damping yourself still wins outright, and a preset used without a duration or bounce override runs exactly the numbers your config gave it.

The shipped presets are the same springs on every driver: quickest, quicker, quick, medium, slow, slowest, lazy, superLazy, bouncy, superBouncy, plus quickestLessBouncy, quickerLessBouncy, and quickLessBouncy. Keep your own table small. A duration is CSS now, so transition="200ms" needs nothing configured, and a name is worth having only when it means something a duration cannot say.

Delay

delay is the second time in the CSS string, or the delay key:

import { Square, XStack } from 'tamagui'
export default () => (
<XStack gap="2">
{[0, 1, 2, 3].map((i) => (
<Square key={i} transition={{ preset: 'bouncy', delay: i * 100 }} opacity="enter:0" scale="enter:0.5" y="enter:20px" size={50} bg="color-10" />
))}
</XStack>
)

Each square starts 100ms after the previous one, on enter, exit, and updates.

Enter/exit transitions

enter and exit set the transition to use while mounting and unmounting. Set them when elements should enter slowly and leave quickly:

import { AnimatePresence, html } from 'tamagui'
export default ({ show }) => (
<AnimatePresence>
{show && (
<html.div key="panel" transition={{ enter: 'lazy', exit: 'quick' }} opacity="enter:0 exit:0" y="enter:20px exit:-20px" />
)}
</AnimatePresence>
)

Each takes a whole transition value, so they can carry their own properties and per-property timings, and the base still applies to changes while mounted:

// enter slowly, exit quickly, changes while mounted use 200ms
<html.div transition={{ duration: 200, enter: { preset: 'lazy', delay: 100 }, exit: 'quick', }} opacity="enter:0 exit:0" />

An enter or exit list replaces the base while it applies rather than merging into it, because the two never run at the same time.

This works with all four animation drivers (CSS, React Native, Reanimated, Motion).

Pseudo-style transitions

Specify different transitions for entering and exiting pseudo states like hover, press, and focus. Entering a state uses that state’s transition, and exiting uses the base transition:

import { Square } from 'tamagui'
export default () => (
<Square size={100} bg="color-5 hover:color-10" transition="1000ms hover:200ms" />
)

In this example:

  • Hover enter: Uses the 200ms transition (snaps quickly to hover state)
  • Hover exit: Uses the 1000ms base transition (fades slowly back)

This works with all pseudo styles:

<html.div transition="slow hover:quick press:bouncy focus:quick" scale="hover:1.1 press:0.95" borderColor="focus:blue-500" />

And with group pseudo states:

<html.div group="card">
<Square size={50} bg="color-5 group-hover/card:color-10" transition="1000ms group-hover/card:200ms" />
</html.div>

Priority follows CSS specificity intuition: press > hover > focus. This works with all animation drivers.

Limiting which properties transition

Name the properties in the transition itself, the same way CSS does. In string form the property leads the value, and in object form it is the properties key:

// only transform transitions, everything else jumps
<Square transition="transform 200ms ease" />
<Square transition={{ preset: 'quick', properties: 'transform, opacity' }} />
// nothing transitions
<Square transition="none" />

This replaces v2’s animateOnly prop, which was the same idea spelled as a separate array. properties reaches every driver, so a list means the same thing on CSS, Motion, React Native, and Reanimated.

Naming transform covers every transform part, so x, y, scale, scaleX/scaleY, rotate and the rest all transition together. Naming one part covers only that part:

// x, scale and rotate all transition
<Square transition="transform 200ms" x={10} scale={1.1} rotate="5deg" />
// only x transitions; scale jumps
<Square transition="x 200ms" x={10} scale={1.1} />

The onTransition lifecycle

Every animated component accepts onTransition, a typed callback that fires at the start and end of a transition. It replaces the untyped, enter-only onDidAnimate from v2.

type TransitionEvent = {
phase: 'start' | 'end'
cause: 'enter' | 'exit' | 'update' // update = style change while mounted
finished?: boolean // on 'end': false when interrupted
}
<html.div transition="quick" onTransition={(e) => { ... }} />

cause is enter when the component mounts into an AnimatePresence, exit when it unmounts, and update for any style change while it stays mounted. On the end phase, finished is false when the transition was interrupted, for example an exit canceled by a re-enter, or an update superseded by another before it settled.

import { html } from 'tamagui'
export default ({ on }) => (
<html.div transition="300ms" opacity={on ? 0.5 : 1} onTransition={(e) => { if (e.phase === 'end' && e.finished) { // the style change finished without being interrupted } }} />
)

A driver may coalesce a multi-property update into one start/end pair per batch, but it never skips the end event. Exit end fires before or with presence removal, so you can observe exit completion without reaching into presence internals. There is a single callback rather than separate onTransitionStart/onTransitionEnd props because those names collide with the React DOM event props on web.

Imperative animation hooks

The same animated-number primitives the drivers use internally are exported from tamagui (and @tamagui/core). Use them to drive an effect from a value you control instead of from style props, for example a drag-linked overlay fade.

import {
useAnimationDriver, // resolved driver from config, throws helpfully on the stub
useAnimatedNumber, // (initial: number) => UniversalAnimatedNumber
useAnimatedNumberStyle, // (value, worklet getStyle) => a style object
useAnimatedNumbersStyle, // multiple values, one style
useAnimatedNumberReaction, // observe the value on the JS thread
} from 'tamagui'

Each hook is a thin delegation to the configured driver, so first-party code and your code exercise the same surface. They throw a helpful error if no animations config is set or if the configured CSS driver came from the core @tamagui/animations-css entry. Import createAnimations from @tamagui/animations-css/extras when using these hooks, Sheet, or draggable Toast with the CSS driver. The resolved driver must not change identity mid-lifecycle, which is only a concern with animatedBy multi-driver setups.

useAnimatedNumberStyle takes a value and a worklet that maps it to a style object. Spread that style onto the driver’s animated view. On the CSS driver, a plain Tamagui view with a transition prop interpolates the change.

import { useRef } from 'react'
import type { Animated } from 'react-native'
import {
Button,
View as TamaguiView,
YStack,
useAnimatedNumber,
useAnimatedNumberStyle,
useAnimationDriver,
} from 'tamagui'
export function Toggle() {
const driver = useAnimationDriver()
// the driver's animated view consumes the returned style; on the css driver
// there is no driver View, so fall back to a Tamagui view + transition prop
const AnimatedView = (driver.View ?? TamaguiView) as typeof Animated.View
const x = useAnimatedNumber(0)
const openRef = useRef(false)
const style = useAnimatedNumberStyle(x, (value: number) => {
'worklet'
return { transform: [{ translateX: value }] }
})
const toggle = () => {
openRef.current = !openRef.current
x.setValue(openRef.current ? 200 : 0, { type: 'spring', damping: 20, stiffness: 90 })
}
return (
<YStack gap="4" padding="4">
<Button onPress={toggle}>Toggle</Button>
<AnimatedView transition="slow" style={[{ width: 60, height: 60, backgroundColor: 'royalblue' }, style]} />
</YStack>
)
}

setValue(next, config, onFinished) accepts an optional completion callback that fires once, with the value settled on the target. useAnimatedNumberReaction observes intermediate values on the JS thread.

UniversalAnimatedNumber, AnimatedNumberStrategy, and the hook types are public API.

What to know when animating

Driver completion

Every driver reports real completion. The CSS driver drives its animated numbers with a requestAnimationFrame ticker and fires completion once when the value settles, so onTransition end events and setValue completion callbacks are accurate on web without an estimated-duration timer. Web completion detection uses the element’s running animations rather than a guessed duration.

Conditional animations and HMR

The animation hooks are heavy, which initially meant we either had to choose great performance or animations. We settled on a trade-off: we track if the transition prop is set, and if so, we enable the hook. If it is ever set, even just once, then the hooks will continue to run for the remainder of the component lifecycle. This means if you ever plan to animate a component you should keep transition always set on the component props. You can disable it like so:

<html.div transition={condition ? 'animation-name' : null} />

Note that because of this constraint, adding the transition prop to a mounted component during dev HMR can error. Saving again or reloading clears it.

See also