Animating Radix Primitives with Framer Motion

Animating Radix Primitives with Framer Motion

Published at 9 May 2024

I love Radix Primitives! They allow you to skip implementing all the boring details of a UI component and focus on its styling. And a lot of times, styling involves animations too.

The easiest way to add animations is to use CSS animations. Radix adds different data- attributes to the elements, so you can target elements in a particular state. Radix even allows you to animate an element's exit: it will wait for the CSS animation to finish before actually removing the element from the page.

But I usually do animations with Framer Motion. Radix works well with it but requires a bit of tinkering with wrapping components in motion and placing forceMount on the correct elements to make everything work together. And I always forgot how to do it properly, so this article is a short guide for both you and me :)

Simple animations

When you want to animate an element's entrance or state change (e.g. from disabled to enabled) with Framer Motion, you need to wrap the Radix component in motion. After this, you can use it as your normal motion.something component. For example, the progress bar:

import { motion } from 'framer-motion';
import * as RadixProgress from '@radix-ui/react-progress';
 
const MotionIndicator = motion(RadixProgress.Indicator);
 
export const ProgressBar = ({ value }: { value: number }) => {
    return (<RadixProgress.Root value={value} className={styles.progress} max={100}>
        <MotionIndicator
            className={styles.indicator}
            animate={{
                translateX: `-${(100 - value)}%`,
            }}
        />
    </RadixProgress.Root>);
};

Alternatively, you could use the asChild prop, provided by Radix.

import { motion } from 'framer-motion';
import * as RadixProgress from '@radix-ui/react-progress';
 
export const ProgressBar2 = ({ value }: { value: number }) => {
    return (<RadixProgress.Root value={value} className={styles.progress} max={100}>
        <RadixProgress.Indicator asChild>
            <motion.div
                className={styles.indicator}
                animate={{
                    translateX: `-${(100 - value)}%`,
                }}
            />
        </RadixProgress.Indicator>
    </RadixProgress.Root>);
};

And with that, you can now use Framer Motion features like animate prop, gestures and layout animations with your Radix components.

Exit animations

When it comes to components that render some of their elements conditionally (for example, a tooltip), it gets a bit more complicated. You can still use enter and state animations the same way as in the previous chapter, but exit animations are more tricky to wire correctly.

By default, Radix manages a component's state (open/closed) by itself. This makes our lives easier by removing boilerplate from our components, but at the same time, it's not really compatible with AnimatePresence from Framer Motion. Unmounted elements need to be direct children of <AnimatePresence> for exit animations to work correctly.

Fortunately, Radix provides an option to give control over the state back to us. Let's take a <Tooltip> as an example. Here is the usual structure of the Radix Tooltip:

import { ReactNode } from 'react';
import * as RadixTooltip from '@radix-ui/react-tooltip';
 
export const Tooltip = ({ children, text }: { children: ReactNode, text: string }) => {
    return (<RadixTooltip.Provider>
        <RadixTooltip.Root>
            <RadixTooltip.Trigger>{children}</RadixTooltip.Trigger>
            <RadixTooltip.Portal>
                <RadixTooltip.Content>
                    {text}
                </RadixTooltip.Content>
            </RadixTooltip.Portal>
        </RadixTooltip.Root>
    </RadixTooltip.Provider>);
};

Tooltip content is rendered by the RadixTooltip.Content element, which in itself is inside a portal. Radix decides on its own when to actually render portal and tooltip content as a response to user action (like hovering over a trigger element). So, how do we take control of our <Tooltip> component?

To do this, we need to introduce a new state variable open and pass it to <RadixTooltip.Root>. And we need to provide a onOpenChange callback to update state when required. This way, Radix still gets to decide when a tooltip should be shown and when hidden, but the state now lives inside our component, and we can use it to conditionally render elements.

import { ReactNode, useState } from 'react';
import * as RadixTooltip from '@radix-ui/react-tooltip';
 
export const Tooltip = ({ children, text }: { children: ReactNode, text: string }) => {
    const [open, setOpen] = useState(false);
    return (<RadixTooltip.Provider>
        <RadixTooltip.Root open={open} onOpenChange={setOpen}>
            <RadixTooltip.Trigger>{children}</RadixTooltip.Trigger>
            <RadixTooltip.Portal>
                <RadixTooltip.Content>
                    {text}
                </RadixTooltip.Content>
            </RadixTooltip.Portal>
        </RadixTooltip.Root>
    </RadixTooltip.Provider>);
};

Now we need to tell Radix that we'll be handling the conditional rendering of the tooltip's content. We do this by passing forceMount property to <RadixTooltip.Portal> and <RadixTooltip.Content> elements. And finally, we need to conditionally render content inside <AnimatePresence>.

import { ReactNode, useState } from 'react';
import * as RadixTooltip from '@radix-ui/react-tooltip';
import { AnimatePresence, motion } from 'framer-motion';
 
// Note: you can also use asChild approach from previous chapter
const MotionContent = motion(RadixTooltip.Content);
 
export const Tooltip = ({ children, text }: { children: ReactNode, text: string }) => {
    const [open, setOpen] = useState(false);
    return (<RadixTooltip.Provider>
        <RadixTooltip.Root open={open} onOpenChange={setOpen}>
            
            <RadixTooltip.Trigger>{children}</RadixTooltip.Trigger>
 
            <RadixTooltip.Portal forceMount>
                <AnimatePresence>
                    {open && <MotionContent forceMount exit={{ opacity: 0 }}>
                        {text}
                    </MotionContent>}
                </AnimatePresence>
            </RadixTooltip.Portal>
 
        </RadixTooltip.Root>
    </RadixTooltip.Provider>);
};

Poof! Your tooltip now supports exit animations. But the same approaches will work with other Radix primitives as well. Now that you are equipped with knowledge, go add some crispy animations to your Radix components!

Published at 9 May 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.