DEV Community

Cover image for 🚂 Framer-Motion: New And Underestimated Features
ShakuroInc
ShakuroInc

Posted on • Updated on

🚂 Framer-Motion: New And Underestimated Features

Introduction

Framer-motion is a motion library that allows you to create declarative animations, layout transitions and gestures while maintaining HTML and SVG element semantics.


⚛️ Beware, framer-motion is React-only library.

In case it might be an issue for you, check out the greensock (framework agnostic animation library) tutorial.


You might be familiar with our previous article on the subject (Framer Motion Tutorials: Make More Advanced Animations). It first came out quite a few versions of this library ago, so in this article we want to:

  • introduce previously overlooked and more recent features of framer-motion.
  • remove styled-components to make demos a bit easier and to lower the entry threshold
  • cover how to use framer-motion with consideration to accessibility (prefers-reduced-motion) and bundle size.

Contents:

  • Setup
  • Keyframes
  • Gesture Animations
  • Accessibility
  • MotionConfig and reducedMotion
  • useReducedMotion
  • How to use prefers-reduced-motion with vanilla CSS and JavaScript
  • Scroll Trigger animation (whileInView)
  • Scroll Linked animations (useViewportScroll, useTransform)
  • Non-custom approach
  • Custom useElementViewportPosition hook
  • Reduce bundle size with LazyMotion
  • Synchronous loading
  • Asynchronous loading
  • Bundle size test
  • Layout animations
  • Shared layout animations
  • Conclusion

Setup

You can start with framer-motion in two easy steps:

Add package to your project: run npm install framer-motion or yarn add framer-motion.
Import motion component.
Start animating with motion component (rename any HTML tag with motion prefix, for example motion.div, motion.p, motion.rect) and configure animation with very straight-forward animate prop.
And ✨ the magic ✨ is already at your fingertips!

import { motion } from 'framer-motion';

export const MotionBox = ({ isAnimating }: { isAnimating: boolean }) => {
 return (
  <motion.div animate={{ opacity: isAnimating ? 1 : 0 }} />;
};
Enter fullscreen mode Exit fullscreen mode

Keyframes

Values in animate can not only take single values (check out the previous example ⬆️) but also an array of values, which is very similar to CSS keyframes.

By default keyframes animation will start with the first value of an array. Current value can be set using null placeholder, to create seamless transitions in case the value was already animating.

<motion.div 
 style={{ transform: 'scale(1.2)' }} 
 animate={{ scale: [null, 0.5, 1, 0] }}
/>

Enter fullscreen mode Exit fullscreen mode

Each value in the keyframes array by default is spaced evenly throughout the animation. To overwrite it and define timing for each keyframe step pass times (an array of values between 0 and 1. It should have the same length as the keyframes animation array) to the transition prop.

<motion.div
     animate={{ opacity: [0, 0.2, 0.8, 1] }}
     transition={{ ease: 'easeInOut', duration: 3, times: [0, 0.5, 0.6, 1] }}
   />
Enter fullscreen mode Exit fullscreen mode

Let’s animate some SVGs. To create a basic rotating loader we need:

rotate: 360 animation and transform-origin: center (originX and originY) on root svg element.
Arrays of keyframes for CSS properties:
stroke-dasharray (pattern of dashes and gaps for outline of the shape).
stroke-dashoffset (offset on the rendering of the dash array).

<motion.svg
     {...irrelevantStyleProps}
     animate={{ rotate: 360 }}
     transition={{ ease: 'linear', repeat: Infinity, duration: 4 }}
     style={{ originX: 'center', originY: 'center' }}
   >
     <circle    {...irrelevantProps} />
     <motion.circle
       {...irrelevantStyleProps}
       animate={{
         strokeDasharray: ['1, 150', '90, 150', '90, 150'],
         strokeDashoffset: [0, -35, -125],
       }}
       transition={{ ease: 'easeInOut', repeat: Infinity, duration: 2 }}
     />
   </motion.svg>
Enter fullscreen mode Exit fullscreen mode

You can compare css keyframes with framer-motion keyframes animation in the demo down below.

I personally see no difference in the difficulty of both realizations.

It’s probably a matter of personal preference or the project’s specificity. In any case, I feel like CSS keyframes should be preferred solution for this type of animation, especially if there are no need for any complex calculations or for the performance concerns (shipping less JavaScript is usually better) and if project is not already using framer-motion for something else obviously.

Gesture animations

Motion component allows us to animate visual representations of interactive elements with whileHover, whileTap, whileDrag and whileFocus props.

Just like with any other type of animation, we can either pass animation directly into props:
<motion.button whileHover={{ scale: 1.2 }}>Submit</motion.button>
Or use variants for more complex customisation:

import { motion, Variants } from 'framer-motion';

const variants: Variants = { variants: { tap: { scale: 0.9 } } };

export const Component = () => {
 return (
   <motion.button variants={variants} whileTap="tap">
     Send
   </motion.button>
 );
};
Enter fullscreen mode Exit fullscreen mode


Similarly to keyframes, in case there is no need for complex calculations or transitions, most of those props (except whileDrag) can be easily replaced with CSS animations.

Accessibility

MotionConfig and reducedMotion

Animations on web pages may be used to attract attention to certain elements or to make UI/UX experience more smooth and enjoyable. But parallax effects, fade-in-out popups or basically any moving or flashing element might cause motion sickness or in any other way might be inconvenient or uncomfortable for a user.

So both for accessibility and performance reasons we need to give an option to restrict or even disable any motion on the page, and, as developers, to respect this choice and make sure it is implemented.

prefers-reduced-motion is a CSS media feature to detect if a user is indicated in their system setting to reduce non-essential motion.

🔗 More information on the subject:

- An Introduction to the Reduced Motion Media Query by Eric Bailey, February 10, 2017
- Respecting Users’ Motion Preferences by Michelle Barker, October 21, 2021

To properly implement prefers-reduced-motion with framer-motion, we can use MotionConfig – a component that allows us to set default options to all child motion components.

At the present moment in only takes 2 props: transition and reducedMotion.

reducedMotion lets us set a policy for handling reduced motion:

  • user – respect user’s device setting;
  • always – enforce reduced motion;
  • never – don’t reduce motion.

In case of reduced motion, transform and layout animations will be disabled and animations like opacityor backgroundColor will stay enabled.

In this particular demo I had trouble dynamically changing reduceMotion value. It wasn’t changing the inner Context.Provider prop unless I passed key={reducedMotion}. This is a very questionable and temporary solution just to make it work, but it at the same time triggers a re-render, which is far away for a perfect and definitely not production-ready solution.

useReducedMotion

For more custom and complex solutions or for any task outside the library’s scope (for example, to disable video autoplaying) we can use useReducedMotion hook:

import { useReducedMotion, motion } from 'framer-motion';

export const Component = () => {
 const shouldReduceMotion = useReducedMotion();
 return(
   <motion.div
    animate={shouldReduceMotion ? { opacity: 1 } : { x: 100 }} 
    />
);
}
Enter fullscreen mode Exit fullscreen mode

It makes me very happy, both as a user and as a developer, that framer-motion is taking accessibility very seriously and gives us proper tools to meet any user’s needs. Even though we have proper JavaScript and CSS solutions to do the same.

How to use prefers-reduced-motion with vanilla CSS and JavaScript

You can also achieve the same thing by using prefers-reduced-motion media-query with CSS:

@media (prefers-reduced-motion: reduce) {
 button {
   animation: none;
 }
}

@media (prefers-reduced-motion: no-preference) {
 button {
   /* `scale-up-and-down` keyframes are defined elsewhere */
   animation: scale-up-and-down 0.3s ease-in-out infinite both;
 }
}
Enter fullscreen mode Exit fullscreen mode

Or window.matchMedia() with JavaScript:

const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

mediaQuery.addEventListener('change', () => {
 const isPrefersReducedMotion = mediaQuery.matches;
 console.log({ isPrefersReducedMotion });

 if (isPrefersReducedMotion === true) {
   // disable animation
 }
});
Enter fullscreen mode Exit fullscreen mode

Scroll Trigger animation (whileInView)

Scroll Trigger animation is a great way to capture users attention and make elements more dynamic.

To create Scroll Trigger animation with framer-motion, let’s use whileInView prop.

<motion.div
 initial={{ opacity: 0, y: -100 }} 
 whileInView={{ opacity: 1, y: 0 }} 
/>
Enter fullscreen mode Exit fullscreen mode

We can also use onViewportEnter and onViewportLeave callbacks, that return IntersectionObserverEntry.

Let’s use variants ⬇️, which make it a bit easier to define more complex animations.

There is also a way to set configuration with viewport props, some of the options we can use:

  • once: boolean – if true, whileInView animation triggers only once.
  • amount: ‘some’ | ‘all’ | number – default: ‘some’. Describes the amount that element has to intersect with viewport in order to be considered in view; number value can be anything from 0 to 1.
import { motion, Variants } from 'framer-motion';

const variants: Variants = {
 hidden: { opacity: 0 },
 visible: { opacity: 1 },
 slideStart: { clipPath: 'inset(0 100% 0 0 round 8px)' },
 slideEnd: { clipPath: 'inset(0 0% 0 0 round 8px)' },
};

export const Component = () => {
 return (
   <motion.div
     variants={variants}
     initial={['hidden', 'slideStart']}
     whileInView={['visible', 'slideEnd']}
     exit={['hidden', 'slideStart']}
     viewport={{ amount: 0.4, once: true }}
   />
 );
};
Enter fullscreen mode Exit fullscreen mode

One unfortunate limitation is that whileInView doesn’t work with transition: { repeat: Infinity }. So there’s no easy way to do infinitely repeating animations that play only in the users viewport. onViewportEnter and onViewportLeave callbacks (with ⚛️ React’s hooks useState and useCallback) is probably the best way to do it.

Scroll Linked animations (useViewportScroll, useTransform)

To take it one step further, let’s talk about Scroll Linked animations, which are a bit similar to Scroll Triggered animations, but more fun! ✨

useViewportScroll and useTransform are animations that are bound to scrolling and scroll position. Scroll Trigger animations give users a more exciting browsing experience. It can be used to capture users’ attention, and it is a great tool for creative storytelling.

Probably the most popular pick for Scroll Linked animations is GreenSock’s ScrollTrigger (just like in our demo).

Unfortunately, compared to GreenSock, Scroll Linked animations with framer-motion will require more custom solutions and take more time to achieve. Let’s find out why and figure out the approach step by step.

Non-custom approach

For Scroll Linked animations with framer-motion, we need:

  1. useViewportScroll hook, that returns 4 different MotionValues, we will use only one of those – scrollYProgress (vertical scroll progress between 0 and 1).
  2. useTransform – hook, that creates a MotionValue that transforms the output of another MotionValue by mapping it from one range of values into another. In this demo, we will use the next set of props to pass to the hook:

-scrollYProgress to sync vertical page scroll with animation;

-[0, 1] – range of scrollYProggress when animation plays;

-[“0%”, “-100%”] – range of x to transform during vertical scroll change.

Long story short, hook transforms x (horizontal transform value) of the motion component while scrollYProgress is in range between 0 and 1 (from start of the page to the very bottom) from 0% to –100%.

It’s not in any way a full explanation of how the hook works and what props it can take. Check out the full documentation for useTransform.

3.To replicate in some way GreenSock’s ScrollTrigger – pinning motion component in viewport while animating we need to wrap it into two wrappers, outer wrapper has to have height > viewport height and inner wrapper has to have position: sticky and top: 0; position: fixed, depending on the animation, might work as well.

Check out the full styling down below ⬇️ or in the sandbox demo.

import { motion, useTransform, useViewportScroll } from 'framer-motion';

export const Component = () => {
 const { scrollYProgress } = useViewportScroll();

 const x = useTransform(scrollYProgress, [0, 1], ['0%', '-100%']);

 return (
   <div style={{ height: '300vh' }}>
     <div
       style={{
         position: 'sticky',
         top: 0,
         height: '100vh',
         width: '100%',
         overflow: 'hidden',
       }}
     >
       <motion.p style={{ x }}>
         Rainbow Rainbow Rainbow
       </motion.p>
     </div>
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

You probably already noticed the main issues with this kind of Scroll Triggered animations. First of all, animations play through the scroll of the whole page and not a portion of it. And the second problem, is that animating x from 0% to -100% makes motion component to scroll out from the viewport (partially or fully, depending on the viewport width) before scroll of the page ends.

Let’s fix these issues in the next chapter ⬇️ by using a custom hook.

Custom useElementViewportPosition hook

To address the issue, described in the previous chapter ⬆️, let’s create a custom hook that will allow us to calculate the range of viewports in which an element is visible.

Custom useElementViewportPosition returns position – value for second prop of useTransform hook – range between 0 and 1 (for example, position = [0.2, 0.95] means, that range is between 20% to 95% of the viewport).

// * based on: https://gist.github.com/coleturner/34396fb826c12fbd88d6591173d178c2
function useElementViewportPosition(ref: React.RefObject<HTMLElement>) {
 const [position, setPosition] = useState<[number, number]>([0, 0]);

 useEffect(() => {
   if (!ref || !ref.current) return;

   const pageHeight = document.body.scrollHeight;
   const start = ref.current.offsetTop;
   const end = start + ref.current.offsetHeight;

   setPosition([start / pageHeight, end / pageHeight]);
   // eslint-disable-next-line react-hooks/exhaustive-deps
 }, []);

 return { position };
}
Enter fullscreen mode Exit fullscreen mode

To figure out the third prop of the useTransform we need to calculate carouselEndPosition – end value to transform to. It basically motion component’s width subtracts the window’s width. Check out the detailed calculations in the sandbox demo.

import { motion, useTransform, useViewportScroll } from 'framer-motion';

export const Component = () => {
 const ref = useRef<HTMLDivElement>(null);
 const carouselRef = useRef<HTMLDivElement>(null);
 const { position } = useElementViewportPosition(ref);
 const [carouselEndPosition, setCarouselEndPosition] = useState(0);
 const { scrollYProgress } = useViewportScroll();
 const x = useTransform(scrollYProgress, position, [0, carouselEndPosition]);

  useEffect(() => {
   // calculate carouselEndPosition
 }, []);

 return (
   <div>
     {/* content */}
     <section ref={ref}>
       <div className="container" style={{ height: "300vh" }}>
         <div className="sticky-wrapper">
           <motion.div ref={carouselRef} className="carousel" style={{ x }}>
             {[0, 1, 2, 3, 4].map((i) => (
               <div key={i} className="carousel__slide">
                 {i + 1}
               </div>
             ))}
           </motion.div>
         </div>
       </div>
     </section>
     {/* content */}
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

Now starting and ending points of animations, as well as transforming values, make the demo look more intentional and well-thought.

I’m not entirely sure that this is the best way to handle this type of the animation with framer-motion but it’s the best I could came up with at this moment. I’m very curious how others solve this. For example, CodeSandbox projects have really fun Scroll Linked animations landing and it looks like it might be built with framer-motion.

Reduce bundle size with LazyMotion

Probably every React ⚛️ developer at some point in their career has to face inflated bundle size problems. Shipping less JavaScript would most likely fix this issue.

To figure out the bundle size of your project and what can be improved, I recommend webpack-bundle-analyzer. After analyzing your bundle, you could find that you have giant package only purpose of which is to make few insignificant changes that could easily replaced with some kind of vanilla solution.

The same concern may be related to animation libraries, which usually have quite significant sizes. If your project is not heavily dependent on complicated animations and/or has bundle size or performance issues, you might consider to partially or fully transfer to CSS animations or even get rid of animations completely. Get your priorities straight: is it really worth using heavy jaw-dropping animations that make most of the low and mid-tear devices lag, overload or overheat? It might or it might not, depending on the project, its purpose and audience.

Fortunately, framer-motion got us covered!

LazyMotion is a very handy component that can help us to reduce bundle size. It synchronously or asynchronously loads some, or all, of the motion component’s features.

Documentation states, that default use of motion component adds around 25kb to the bundle size vs under 5kb for LazyMotion and m components.

Synchronous loading

How exactly can we achieve up to 5x bundle size reduction? With synchronous loading. By wrapping animation component with LazyMotion and passing needed features (domAnimation or domMax) to the features prop. The last step is to replace the regular motion component with its smaller twin – m component.

import { LazyMotion, domAnimation, m } from 'framer-motion';

export const MotionBox = ({ isAnimating }: { isAnimating: booolean }) => (
 <LazyMotion features={domAnimation}>
   <m.div animate={isAnimating ? { x: 100 } : { x: 0 }} />
 </LazyMotion>
);
Enter fullscreen mode Exit fullscreen mode

Asynchronous loading

We can save users a few more KB by using async loading of LazyMotion features.

Image description

// dom-max.ts

export { domMax } from 'framer-motion';

// modal.tsx

import { LazyMotion, m } from 'framer-motion';

const loadDomMaxFeatures = () =>
 import('./dom-max').then(res => res.domMax);

export const Modal = ({ isOpen }: { isOpen: booolean}) => (
 <AnimatePresence exitBeforeEnter initial={false}>
   {isOpen && (
     <LazyMotion features={loadDomMaxFeatures} strict>
       <m.div
         variants={ { open: { opacity: 1 }, collapsed: { opacity: 0 } } }
         initial="collapsed"
         animate="open"
         exit="collapse"
       >
         // modal content
       <m.div>
     </LazyMotion>
   }
 </AnimatePresence>
);
Enter fullscreen mode Exit fullscreen mode

📎 AnimatePresence component is not required in this case, but it’s pretty much a necessity for any animations on component unmount. You can learn more about AnimatePresence here.
Modals, accordions, carousels, and pretty much any other animation that requires a user’s interaction can benefit from that.

Bundle size test

I tested how much LazyMotion can decrease bundle size on our live projects (loading features synchronously):

Image description

It’s not much, but I’m pretty happy with the results. Considering how little effort it takes to implement, a 5-10KB reduction in bundle size is quite significant.

Layout animations

To automatically animate layout of motion component pass layout prop.

<motion.div layout />
Enter fullscreen mode Exit fullscreen mode

It doesn’t matter which CSS property will cause layout change (width, height, flex-direction, etc) framer-motion will animate it with transform to ensure best possible performance.

import { useCallback, useState } from 'react';
import { motion } from 'framer-motion';

const flexDirectionValues = ['column', 'row'];

export const LayoutAnimationComponent = () => {
 const [flexDirection, setFlexDirection] = useState(flexDirectionValues[0]);

 const handleFlexDirection = useCallback(({ target }) => {
   setFlexDirection(target.value);
 },[]);

 return (
   <div>
     {flexDirectionValues.map((value) => (
       <button
         key={value}
         value={value}
         onClick={handleFlexDirection}
       >
         {value}
       </button>
     ))}
     <div style={{ display: 'flex', flexDirection }}>
       {[1, 2, 3].map((item) => (
         <motion.div layout key={item} transition={{ type: 'spring' }} />
       ))}
     </div>
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

At first, I didn’t realize how easy, useful and multifunctional layout animations are, but it’s truly rare!

Shared layout animations

Similarly to layout animations, shared layout animations give us the opportunity to automatically animate motion components between each other. To do it, simply give multiple motion components same layoutId prop.

import { motion } from 'framer-motion';

const bullets = [1, 2, 3];

export const Bullets = ({ currentIndex }: { currentIndex: number }) => {
 return (
   <div className="bullets">
     {bullets.map((bullet, index) => (
       <div key={index} className="bullet">
         <button>{bullet}</button>
         {index === currentIndex && <motion.div layoutId="indicator" className="indicator" />
          )}
       </div>
     ))}
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

Take a look at the turquoise dot under the slider’s navigation/bullet button and try to navigate. When a dot is animated by smoothly moving from one bullet to another, it looks like the same element, but it’s actually not!

This particular demo might seem a bit excessive, considering our point of interest here is only one little dot. But it also uses AnimatePresence component to perform exit animation before the component unmounts, which might be extremely useful in so many different use cases. Check out Sandbox demo to see how to use AnimatePresence.

Conclusion
Since first time I used framer-motion (December 2019), it continued to grow and evolve and now this library gives us opportunity to easily bring to life almost any animation.

I feel like with animations it’s really easy to mess up at least some if not all aspects of good UI/UX experience. But framer-motion comes to the rescue with:

  • Maintaining HTML and SVG semantics
  • declarative and keyframes animations
  • considerations to accessibility (MotionConfig + reducedMotion)
  • bundle size reduction (LazyMotion + m)
  • layout transitions (layout and layoutId)
  • scroll triggered (whileInView) and scroll linked animations (useViewportScroll).

From a web developer’s perspective, working with framer-motion is very enjoyable and fun. It might or it might be the same for you, try it out to see for yourself, fortunately, it’s very easy to start with.

At the end of this article, I want to give you one last friendly reminder: animation is fun and exciting, but it is not a crucial element of a great user experience and – if not used intentionally and in moderation – a possible way to make it worse. You could get away with using simple CSS transitions or keyframes and still achieve excellent results without taking away much from your final product.

Top comments (2)

Collapse
 
thomasbnt profile image
Thomas Bnt ☕

Hello ! Don't hesitate to put colors on your codeblock like this example for have to have a better understanding of your code 😎

console.log('Hello world!');
Enter fullscreen mode Exit fullscreen mode

Example of how to add colors and syntax in codeblocks

Collapse
 
okayhead profile image
Shashwat jaiswal

Great article!
I must say the functionality for prefers-reduced-motion is rather limited with the MotionConfig wrapper.

From the docs

When motion is reduced, transform and layout animations will be disabled. Other animations, like opacity and backgroundColor, will persist.

This is a rather lazy handed approach. They could've reduced the transition duration to 0.1 or even 0 but kept the transforms. Removing the transforms altogether is not intutive.
Also the way to make it re-render is to pass the state as a key prop to motion config which does seem a bit hacky and unreliable.