Creating Animated Scroll Components with Tailwind CSS and Framer Motion

At Arcus Digital, we take pride in crafting products that are reactive, responsive, and delightful for end users. In many of our projects, achieving this involves creating animations that provide feedback for user interactions or simply add visual interest to the interface. However, maintaining clean, DRY animation logic that is responsive to various screen sizes can be a challenge.

Recently, we encountered this exact challenge on an animation-heavy project. After thorough research, we landed on this solution: combining Tailwind CSS (our preferred CSS solution) with Framer Motion and employing abstraction to create a wrapper component that could be utilized throughout the entire application.

Lets begin with a simplified set of requirements for the purposes of illustration:

Elements need to fade in and move from the left, right, top, or bottom as they come into view.

Control over their behavior based on screen size is necessary.

Centralized logic to facilitate rapid iteration with our design team is necessary.

The first requirement is simple enough. We can use Framer Motion's motion component with the 'whileInView' prop.

<motion.div
    initial={{ opacity: 0, y: '20px' }}
    whileInView={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.5 }}
    viewport={{ once: true }}
>

Great! Here, the initial property defines the starting position, the whileInView property defines the position when in view, and the viewport property ensures the animation occurs only once. But what if we want different animations for phones and laptops? Tailwind comes to the rescue. We can use Tailwind's breakpoints to create CSS variables specific to screen sizes. By providing a fallback to the motion div, we can choose the animation based on screen size.

<motion.div
    initial={{
        y: 'var(--y-from-bottom, 0px)',
        x: 'var(--x-from-left, 0)',
        opacity: 0,
    }}
    whileInView={{
        y: 0,
        x: 0,
        opacity: 1,
    }}
    transition={{ duration: 0.5 }}
    viewport={{ once: true }}
    className={
        'max-lg:[--y-from-bottom:20px] lg:[--x-from-left:-20px]'
    }
>

Fantastic! Yet, this seems like a substantial amount of code for every scroll animation. And what if we need components to come in from different directions? Here's where we abstract our work into a wrapper, fulfilling our final requirement.

import { motion } from 'framer-motion';
import { ReactNode } from 'react';

export interface Props {
  right?: boolean;
  top?: boolean;
  classNames?: string;
  children: ReactNode;
}

export default function ScrollWrapper({
  right,
  top,
  classNames,
  children,
}: Props) {
  const variants = {
    initial: {
      y: top ? 'var(--y-from-top, 0px)' : 'var(--y-from-bottom, 0px)',
      x: right ? 'var(--x-from-right, 0)' : 'var(--x-from-left, 0)',
      opacity: 0,
    },
    whileInView: {
      y: 0,
      x: 0,
      opacity: 1,
    },
    viewport: {
      once: true,
    },
    transition: {
      duration: 0.5,
    },
  };

  return (
    <motion.div
      variants={variants}
      initial="initial"
      whileInView="whileInView"
      viewport={variants.viewport}
      transition={variants.transition}
      className={
        classNames + 'max-lg:[--y-from-top:-20px] max-lg:[--y-from-bottom:20px] lg:[--x-from-right:20px] lg:[--x-from-left:-20px]'
      }
    >
      {children}
    </motion.div>
  );
}

Finally! We've created a React component that takes animation-related props. The usage is now straightforward - wrap the component to be animated with our ScrollWrapper. Any changes to scroll animations from the design team can be made in this file and reflected throughout the project.

<ScrollWrapper>
  <div>
    Whatever the content may be
  </div>
</ScrollWrapper>