Animations

import { LogoIcon } from '@tamagui/logo'
import { Play } from '@tamagui/lucide-icons'
import { Button, Square, useControllableState, useEvent } from 'tamagui'
export function AnimationsDemo(props) {
const [positionI, setPositionI] = useControllableState({
strategy: 'most-recent-wins',
prop: props.position,
defaultProp: 0,
})
const position = positions[positionI]
const onPress = useEvent(() => {
setPositionI((x) => {
return (x + 1) % positions.length
})
})
return (
<>
<Square animation={(props.animation || 'bouncy') as any} animateOnly={['transform']} onPress={onPress} size={104} borderColor="$borderColor" borderWidth={1} borderRadius="$9" backgroundColor="$color9" hoverStyle={{ scale: 1.5, }} pressStyle={{ scale: 0.9, }} {...position} >
{props.children || <LogoIcon downscale={0.75} />}
</Square>
<Button position="absolute" bottom={20} left={20} icon={Play} theme={props.tint} size="$5" circular onPress={onPress} />
</>
)
}
export const positions = [
{
x: 0,
y: 0,
scale: 1,
rotate: '0deg',
},
{
x: -50,
y: -50,
scale: 0.5,
rotate: '-45deg',
hoverStyle: {
scale: 0.6,
},
pressStyle: {
scale: 0.4,
},
},
{
x: 50,
y: 50,
scale: 1,
rotate: '180deg',
hoverStyle: {
scale: 1.1,
},
pressStyle: {
scale: 0.9,
},
},
]

Features

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

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

  • Three 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. See the configuration docs for more on how to set it up, and how to set up different animation drivers per-platform.

Animation drivers are designed to be swappable, so you can use lightweight CSS animations or other web-focused animation libraries on the web, while using larger but more advanced libraries like reanimated on native - all without having to change a line outside of configuration.

Installation

CSS

The @tamagui/animations-css package works with the tamagui compiler and runtime to give you simple ways to share typed animations across all your components.

To install it add to your package.json:

yarn add @tamagui/animations-css

Then add it to your config:

import { createAnimations } from '@tamagui/animations-css'
import { createTamagui } from 'tamagui'
export default createTamagui({
animations: createAnimations({
fast: 'ease-in 150ms',
medium: 'ease-in 300ms',
slow: 'ease-in 450ms',
}),
// ...
})

At runtime, the plugin does very little except to set the transition property in CSS. At compile-time, the compiler does the same, ensuring you get all the benefits of prop removal and view flattening even when using animations.

React Native Animated

React Native's Animated library  is the animation library that comes built into React Native and React Native Web.

To install it add to your package.json:

yarn add @tamagui/animations-react-native

Then add it to your config:

import { createAnimations } from '@tamagui/animations-react-native'
import { createTamagui } from 'tamagui'
export default createTamagui({
animations: createAnimations({
fast: {
damping: 20,
mass: 1.2,
stiffness: 250,
},
medium: {
damping: 10,
mass: 0.9,
stiffness: 100,
},
slow: {
damping: 20,
stiffness: 60,
},
}),
// ...
})

Reanimated

Reanimated  is an animation library that targets React Native and React Native Web. It runs off-thread animations, and provides simple syntax and hooks.

To install it add to your package.json:

yarn add @tamagui/animations-moti react-native-reanimated

Tamagui leverages and appreciates the popular open source library Moti  for the Reanimated driver as it saved us many lines of complex code.

Add your animations to your configuration:

import { createAnimations } from '@tamagui/animations-moti'
import { createTamagui } from 'tamagui'
export default createTamagui({
animations: createAnimations({
fast: {
type: 'spring',
damping: 20,
mass: 1.2,
stiffness: 250,
},
medium: {
type: 'spring',
damping: 10,
mass: 0.9,
stiffness: 100,
},
slow: {
type: 'spring',
damping: 20,
stiffness: 60,
},
}),
// ...
})

At runtime, this plugin parses animatable style properties and hands them over to reanimated off-thread, using worklets. It doesn't do anything at compile time - reanimated must run via JS.

wwwwwwwwwwwwwwwwwww

Note the keys match between CSS and reanimated, so you can swap them out.

The Animated driver in React Native Web can be excluded from your bundle with either the Webpack or Next.js plugins with excludeReactNativeWebExports compiler option.

Usage

The animation can now accept slow as a value. By default, animations will apply to all animatable styles, a lot like setting all in a CSS transition. Let's test this by setting hoverStyle:

Here's an example animating hoverStyle:

import { LogoIcon } from '@tamagui/logo'
import { Square } from 'tamagui'
export function AnimationsHoverDemo() {
return (
<Square borderColor="$borderColor" animation="bouncy" elevation="$4" backgroundColor="$color9" size={104} borderRadius="$9" hoverStyle={{ scale: 1.2, }} pressStyle={{ scale: 0.9, }} >
<LogoIcon downscale={0.75} />
</Square>
)
}

The animation props rules

If you add an animation 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 degrate runtime performance if present on every component. As a workaround, the animation hooks are called conditionally on whether the animation key is present in the props object.

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

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

enterStyle

Setting enterStyle styles on any component tell it to start with those styles, and after mount animate to their flat styles:

import { LogoIcon } from '@tamagui/logo'
import { useRef, useState } from 'react'
import { Button, Square, YStack, isWeb } from 'tamagui'
import { useIsIntersecting } from './useOnIntersecting'
export function AnimationsEnterDemo(props: any) {
const ref = useRef<HTMLElement>(null)
const [key, setKey] = useState(0)
if (isWeb) {
const hasIntersected = useIsIntersecting(ref, { once: true })
if (!hasIntersected) {
return <YStack ref={ref} />
}
}
return (
<>
<Square key={key} enterStyle={{ scale: 1.5, y: -10, opacity: 0, }} animation="bouncy" elevation="$4" size={110} opacity={1} scale={1} y={0} backgroundColor="$pink10" borderRadius="$9" >
{props.children ?? <LogoIcon downscale={0.75} />}
</Square>
<Button size="$3" marginTop="$4" onPress={() => setKey(Math.random())}>
Re-mount
</Button>
</>
)
}

Granular animations

The animation prop accepts a string or a more complex object to customize animations per-property.

The basic object we'll call an AnimationConfig, looks like this:

import { YStack } from 'tamagui'
export default () => (
<YStack animation={{ // only x and y will apply animations x: 'bouncy', y: { type: 'bouncy', overshootClamping: true, }, }} />
)

Note that values can either map to AnimationKey as a string, or to { type: AnimationKey, ...configuration }

You can set a default value using a two-arity array with the default in the first position:

import { YStack } from 'tamagui'
export default () => (
<YStack animation={[ // all attributes get "bouncy" 'bouncy', // these are customized { y: 'slow', scale: { type: 'fast', repeat: 2, }, }, ]} />
)

animateOnly

The animateOnly prop will limit your animation config to certain keys. It accepts an array of strings that correspond to style property names.

AnimatePresence

exitStyle

AnimatePresence animates direct children before they unmount. It's inspired by and forked from Framer Motion , but works with any animation in Tamagui.

To use with @tamagui/core, install and import @tamagui/animate-presence. It's already bundled and exported from tamagui.

You can use it simply with enterStyle + exitStyle:

import { AnimatePresence, View } from 'tamagui'
export const MyComponent = ({ isVisible }) => (
<AnimatePresence>
{isVisible && (
<View key="my-square" animation="bouncy" backgroundColor="green" size={50} enterStyle={{ opacity: 0, y: 10, scale: 0.9, }} exitStyle={{ opacity: 0, y: -10, scale: 0.9, }} />
)}
</AnimatePresence>
)

Note you don't even need to set opacity on the base style. Tamagui knows to normalize styles like opacity and scale to 1 (and y to 0) if it's not defined on the base styles but is defined on enterStyle or exitStyle.

Wrap one or more tamagui components with AnimatePresence. This component will animate on enter and exit.

Animated child components must each have a unique key prop so AnimatePresence can track their presence in the tree.

The custom prop

custom
import { AnimatePresence } from '@tamagui/animate-presence'
import { ArrowLeft, ArrowRight } from '@tamagui/lucide-icons'
import { useState } from 'react'
import { Button, Image, XStack, YStack, styled } from 'tamagui'
// @ts-ignore
import photo1 from '../../public/photo1.jpg'
// @ts-ignore
import photo2 from '../../public/photo2.jpg'
// @ts-ignore
import photo3 from '../../public/photo3.jpg'
export const images = [photo1, photo2, photo3].map((x) => x.src || x)
const GalleryItem = styled(YStack, {
zIndex: 1,
x: 0,
opacity: 1,
fullscreen: true,
variants: {
// 1 = right, 0 = nowhere, -1 = left
going: {
':number': (going) => ({
enterStyle: {
x: going > 0 ? 1000 : -1000,
opacity: 0,
},
exitStyle: {
zIndex: 0,
x: going < 0 ? 1000 : -1000,
opacity: 0,
},
}),
},
} as const,
})
const wrap = (min: number, max: number, v: number) => {
const rangeSize = max - min
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
}
export function AnimationsPresenceDemo() {
const [[page, going], setPage] = useState([0, 0])
const imageIndex = wrap(0, images.length, page)
const paginate = (going: number) => {
setPage([page + going, going])
}
return (
<XStack overflow="hidden" backgroundColor="#000" position="relative" height={300} width="100%" alignItems="center" >
<AnimatePresence initial={false} custom={{ going }}>
<GalleryItem key={page} animation="slow" going={going}>
<Image source={{ uri: images[imageIndex], width: 820, height: 300 }} />
</GalleryItem>
</AnimatePresence>
<Button accessibilityLabel="Carousel left" icon={ArrowLeft} size="$5" position="absolute" left="$4" circular elevate onPress={() => paginate(-1)} zi={100} />
<Button accessibilityLabel="Carousel right" icon={ArrowRight} size="$5" position="absolute" right="$4" circular elevate onPress={() => paginate(1)} zi={100} />
</XStack>
)
}

AnimatePresence also takes a custom property that allows you to update a variant of the animated child before it runs it's exit animation. This is useful for animating a child out of the screen before it unmounts in a different direction, like the example above:

import { AnimatePresence } from '@tamagui/animate-presence'
import { ArrowLeft, ArrowRight } from '@tamagui/lucide-icons'
import { useState } from 'react'
import { Button, Image, XStack, YStack, styled } from 'tamagui'
const GalleryItem = styled(YStack, {
zIndex: 1,
x: 0,
opacity: 1,
fullscreen: true,
variants: {
// 1 = right, 0 = nowhere, -1 = left
going: {
':number': (going) => ({
enterStyle: {
x: going > 0 ? 1000 : -1000,
opacity: 0,
},
exitStyle: {
zIndex: 0,
x: going < 0 ? 1000 : -1000,
opacity: 0,
},
}),
},
} as const,
})
const photos = [
'https://picsum.photos/500/300',
'https://picsum.photos/501/300',
'https://picsum.photos/502/300',
]
const wrap = (min: number, max: number, v: number) => {
const rangeSize = max - min
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
}
export function Demo() {
const [[page, going], setPage] = useState([0, 0])
const imageIndex = wrap(0, photos.length, page)
const paginate = (going: number) => {
setPage([page + going, going])
}
return (
<XStack overflow="hidden" backgroundColor="#000" position="relative" height={300} width="100%" alignItems="center" >
<AnimatePresence initial={false} custom={{ going }}>
<GalleryItem key={page} animation="slowest" going={going}>
<Image source={{ uri: photos[imageIndex], width: 500, height: 300 }} />
</GalleryItem>
</AnimatePresence>
<Button accessibilityLabel="Carousel left" icon={ArrowLeft} size="$5" position="absolute" left="$4" circular elevate onPress={() => paginate(-1)} zi={100} />
<Button accessibilityLabel="Carousel right" icon={ArrowRight} size="$5" position="absolute" right="$4" circular elevate onPress={() => paginate(1)} zi={100} />
</XStack>
)
}

What to know when animating

Conditional animations and HMR

The animation hooks are heavy, which initially meant we either had to choose great performance or animations. We've settled on a trade-off. We track if the animation 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 animation always set on the component props. You can disable it like so:

<View animation={condition ? 'animation-name' : null} />

Note that because of this constraint, you also will see an error if you add the animation prop to a component in dev mode during an HMR. Often just saving once more will remove the screen, or reloading at worst.