Direction-aware animations in Framer Motion

Direction-aware animations in Framer Motion

Published at 4 February 2024

Sometimes, when adding animations to your website or app, you want the animations to go into different directions depending on the context. For example, when navigating between screens, you might want the current screen to slide out to the left and the new screen to slide in from the right. But when you go back, animations should be reversed: the current screen slides out to the right and the new screen slides from the left. Take a look at this example:

My imaginary spice shop

Even though you navigate back (and in western cultures, it's associated with the left side), the product list comes from the right side.

A more radical example is the carousel, where you absolutely need to change enter/exit animations depending on the direction the user is going.

Zephyr Zest

$3.99
A fragrant blend of rare mountain herbs, perfect for adding a light, airy touch to salads and pastas.

This feels wrong!

So how do we fix this? Let's continue with the navigation example. It looks like this:

export const ProblemNavigation = () => {
    const [product, setProduct] = useState<Product | null>(null);
    return (<div>
        <MotionConfig transition={{ type: 'spring', duration: 0.55 }}>
            <AnimatePresence initial={false} mode="sync">
                {product
                    ? <ProductDetails 
                        product={product} 
                        onBack={() => setProduct(null)} 
                        key="details" 
                    />
                    : <ProductsList 
                        openProduct={setProduct} 
                        key="list" 
                    />
                }
            </AnimatePresence>
        </MotionConfig>
    </div>);
};

Here, both ProductDetails and ProductsList are motion.div with predefined animations:

const variants = {
    initial: {
        x: '130%',
    },
    target: {
        x: '0%',
    },
    exit: {
        x: '-130%',
    },
};
 
const ProductDetails = () => {
    return (<motion.div
        variants={variants}
        initial="initial"
        animate="target"
        exit="exit"
    >
        {/* ... */}
    </motion.div>)
};

This adds an enter and exit animation to each screen, but the problem is that it's always in one direction. To solve this, you might be tempted to just pass direction as a prop and change the transition based on it, just like this:

const ProductDetails = ({ direction }: { direction: 'back' | 'forward' }) => {
    return (<motion.div
        initial={{x: direction === 'forward' ? '130%' : '-130%'}}
        animate={{x: '0%'}}
        exit={{x: direction === 'forward' ? '-130%' : '130%'}}
    >
        {/* ... */}
    </motion.div>)
};

That kinda works, but only for enter animations. When a component is unmounted, it doesn't receive new props. It's kept on the page by Framer Motion, but for React it's already gone. So even if the direction changes, the exiting component will use the older value, which it received on the previous render.

Solution

To solve this, we need to pass direction using Framer Motion instead of props. This way, our component will be able to get the latest value for direction, even if it's already unmounted. To do this, we need to define our variants not as static values, but as functions that accept the argument custom.

type Direction = 'back' | 'forward';
 
const variants = {
    initial: (direction: Direction) => ({
        x: direction === 'forward' ? '130%' : '-130%',
    }),
    target: {
        x: '0%',
    },
    exit: (direction: Direction) => ({
        x: direction === 'forward' ? '-130%' : '130%',
    }),
};

And then add custom prop to our pages.

const ProductDetails = ({ direction }: { direction: 'back' | 'forward' }) => {
    return (<motion.div
        variants={variants}
        custom={direction}
        initial="initial"
        animate="target"
        exit="exit"
    >
        {/* ... */}
    </motion.div>)
};

This will ensure the correct direction for enter animations. To fix exit animations, we also need to pass custom to AnimatePresence.

export const SolutionNavigation = () => {
    const [product, setProduct] = useState<Product | null>(null);
    // If the product is set, then we're navigating to product details; otherwise, to the product list
    const direction: Direction = product ? 'forward' : 'back';
 
    return (<>
        <div>
            <MotionConfig transition={{ type: 'spring', duration: 0.55 }}>
                <AnimatePresence initial={false} mode="sync" custom={direction}>
                    {product
                        ? <ProductDetails direction={direction} product={product} onBack={() => setProduct(null)} key="details" />
                        : <ProductsList direction={direction} openProduct={setProduct} key="list" />
                    }
                </AnimatePresence>
            </MotionConfig>
        </div>
    </>);
};

Since AnimatePresence is always present in the tree, it will always have the latest (and correct) version of custom which it will pass (and overwrite value from component's props) to any unmounting component.

Interactive demo:

My imaginary spice shop

The way of calculating the correct direction will depend on the structure of your app. In the example above, it's relatively easy; for carousels, it would look like this:

const usePrevious = <T,>(val: T) => {
    const ref = useRef<undefined | T>(undefined);
    useEffect(() => {
        ref.current = val;
    });
    return ref.current;
}
 
export const SolutionCarousel = () => {
    const [activeIndex, setActiveIndex] = useState(0);
    const previousIndex = usePrevious(activeIndex) ?? activeIndex;
    const product = products[activeIndex];
 
    // last element -> first element
    const direction: Direction = (activeIndex === 0 && previousIndex !== 1)
        ? 'forward'
        // first element -> last element
        : (previousIndex === 0 && activeIndex !== 1)
            ? 'back'
            // switched to neighbouring element
            : previousIndex > activeIndex
                ? 'back'
                : 'forward';
    return (<>
        <div>
            <MotionConfig transition={{ type: 'spring', duration: 0.55 }}>
                <AnimatePresence initial={false} mode="sync" custom={direction}>
                    <ProductDetails
                        direction={direction}
                        product={product}
                        onBack={() => {
                            setActiveIndex(prev => prev === 0 ? products.length - 1 : prev - 1);
                        }}
                        onForward={() => {
                            setActiveIndex(prev => prev === products.length - 1 ? 0 : prev + 1);
                        }}
                        key={product.name}
                    />
                </AnimatePresence>
            </MotionConfig>
        </div>
    </>);
};

Interactive demo:

Zephyr Zest

$3.99
A fragrant blend of rare mountain herbs, perfect for adding a light, airy touch to salads and pastas.

Reusable solution

We can make this code a bit cleaner by using context to pass direction around and a custom hook to group all animation-related props.

type Direction = 'back' | 'forward';
 
const variants = {
    initial: (direction: Direction) => ({
        x: direction === 'forward' ? '130%' : '-130%',
    }),
    target: {
        x: '0%',
    },
    exit: (direction: Direction) => ({
        x: direction === 'forward' ? '-130%' : '130%',
    }),
};
 
const DirectionContext = createContext<Direction>('forward');
 
type AnimatePresenceWithDirectionProps = { 
    children: ReactNode,
    direction: Direction 
} & Omit<AnimatePresenceProps, 'custom'>;
 
const AnimatePresenceWithDirection = ({ children, direction, ...props }: AnimatePresenceWithDirectionProps) => {
    return (<DirectionContext.Provider value={direction}>
        <AnimatePresence {...props} custom={direction}>
            {children}
        </AnimatePresence>
    </DirectionContext.Provider>);
};
 
const useDirectionAnimation = () => {
    const direction = useContext(DirectionContext);
 
    return {
        variants,
        custom: direction,
        initial: 'initial',
        animate: 'target',
        exit: 'exit',
    }
};

Usage:

// This code is mostly the same as in the previous example
export const DirectionAwareAnimationsReusableSolution = () => {
    const [product, setProduct] = useState<Product | null>(null);
    const direction: Direction = product ? 'forward' : 'back';
 
    return (<div>
        <MotionConfig transition={{ type: 'spring', duration: 0.55 }}>
            {/* But now we use AnimatePresenceWithDirection */}
            <AnimatePresenceWithDirection initial={false} mode="sync" direction={direction}>
                {product
                    ? <ProductDetails product={product} onBack={() => setProduct(null)} key="details" />
                    : <ProductsList openProduct={setProduct} key="list" />
                }
            </AnimatePresenceWithDirection>
        </MotionConfig>
    </div>);
};
 
export const ProductDetails = () => {
    const animationProps = useDirectionAnimation();
 
    // Just spread returned props on motion component
    return <motion.div {...animationProps}>
        {/* ... */}
    </motion.div>
});
Published at 4 February 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.

You may also like: