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 }} />;
};
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] }}
/>
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] }}
/>
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>
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>
);
};
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 opacity
or 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 }}
/>
);
}
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;
}
}
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
}
});
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 }}
/>
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 }}
/>
);
};
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:
-
useViewportScroll
hook, that returns 4 differentMotionValues
, we will use only one of those –scrollYProgress
(vertical scroll progress between0
and1
). -
useTransform
– hook, that creates aMotionValue
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>
);
};
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 };
}
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>
);
};
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>
);
Asynchronous loading
We can save users a few more KB by using async loading of LazyMotion
features
.
// 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>
);
📎 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):
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 />
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>
);
};
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>
);
};
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
andlayoutId
) - 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)
Hello ! Don't hesitate to put colors on your
codeblock
like this example for have to have a better understanding of your code 😎Great article!
I must say the functionality for prefers-reduced-motion is rather limited with the MotionConfig wrapper.
From the docs
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.