Back

Animated tabs with React Router and Framer Motion

October 28, 2019

Tabs are a staple of website design, maintaining an impressive ubiquity throughout the years. Their extended tenure in the lexicography of the web is likely due to the pattern’s relative simplicity — at this point tabs are essentially part of the furniture.

Which is to say tabs are in dire need of an injection of excitement. Thrills, spills. It’s about time we took a sledge-hammer to our false tab idols and started anew.

What I’m talking about is adding a moving underline that highlights the currently active tab. Please, hold your applause.

To be more specific, I’d like to show you how to build a funky tab component in React which uses React Router to maintain the state and Framer Motion to handle the animations, that will be ready to eat in 10 minutes or less.


Ingredients:

  • 1 tablespoon React 16.8+
  • 1 cup React Router
  • 1 serving Framer Motion
  • A sprinkling of CSS

To get started, we’re going to be working with React. If you’re not familiar, please leave, save yourself, before it’s evil grasp clutches you and you are doomed like the rest of us, cursed to wander the Earth forever more mumbling incoherent nonsense about hooks and higher order components.

To get started, I’m going to assume that you’ve got yourself a fine and dandy React project in front of you. If you don’t, you may want to take a look at Create React App or other boilerplates.

While we’re feeling bold, let’s install the other dependencies we’ll be using for these tabs:

We’ll be using React Router to handle the whole hiding/showing of tabbed content for us. React Router is a routing library for React, as unbelievable as that might sound. What we will essentially be doing is setting each tab up as a “route”, and then have a list of links to these above, which will act as our tab buttons.

So let’s install React Router in our project:

npm install react-router-dom

For our purposes, I’m going to wrap my project in the much-maligned HashRouter, in the interest of time/sanity. But you should really be the master of your own destiny here and choose the router which makes sense for your project and needs.

We’ll be having our Tabs component take in an array of items as a prop. This prop will be a list of the tabs and their content.

import React from "react"
import Tabs from "tabs"
import { HashRouter as Router } from "react-router-dom"

const App = () => (
  <div className="wrapper">
    <Router>
      <Tabs
        items={[
          {
            name: "Tab #1", // The tab title text
            route: "id1", // The route / unique identifier for the tab
            render: () => <div>A render function for the tab's contents</div>,
          },
        ]}
      />
    </Router>
  </div>
)

Okay, now for the good stuff. With React Router, we can quickly throw together a simple tabs component, just by using a combination of Link components as the tabs, paired with Route components for their relevant content.

import React from "react"
import { Switch, Route, Redirect, Link } from "react-router-dom"

const Tabs = ({ items }) => (
  <React.Fragment>
    <ul role="tablist" aria-orientation="horizontal" className="tabs-list">
      {/* We loop through the "items" and create tabs for each */}
      {items.map(item => (
        <li className="tabs-list__item" key={`tab-${item.route}`}>
          <Link to={item.route} className="tabs-list__tab">
            {item.name}
          </Link>
        </li>
      ))}
    </ul>
    {/* Tab content - we use a Switch to only show one tab at a time */}
    <Switch>
      {/* We loop through the "items" and create routes to correspond to each tab */}
      {items.map(item => (
        <Route key={item.route} path={`/${item.route}`} render={item.render} />
      ))}
      {/* We can have a Redirect route to ensure we always have an active route */}
      <Route render={() => <Redirect to={items[0] ? items[0].route : "/"} />} />
    </Switch>
  </React.Fragment>
)

export default Tabs

Bingo bango, there you have it. With a very conservative amount of code, we have a fully functioning (if quite austere) tabs component.

If you’re feeling in a festive spirit, you might like to decorate your newly born tabs, by adding some simple CSS to style the tabs. I’ve gone with using flexbox to align the tabs side-by-side, as is my want.

/* The list of tabs */
.tabs-list {
  display: flex;
  border-bottom: 1px solid #ecf0f5;
}

.tabs-list__item {
  margin-right: 30px;
}

/* The individual tab buttons */
.tabs-list__tab {
  display: block;
  color: #2c3642;
  padding: 18px 6px;
  text-decoration: none;
  font-weight: bold;
  white-space: nowrap;
  position: relative;
}

.tabs-list__tab:hover {
  color: green;
}

If you look at your browser’s address bar, you might notice the URL changing as you click on each tab. This is how our tabs are working - React Router is getting the active tab from the current path, and each tab is an anchor tag linking to the new active tab. This gives our tabs some nice benefits:

  • We can link into specific tabs, by appending the tab’s route to the URL
  • The browser history is updated as we flick between tabs, so the native browser navigation (back/forwards) will work

Knowing this, we can add some code to set an active class on the tab, for the benefit of adding some styling to it. We can use React Router’s withRouter Higher Order Component (HOC) to wrap our Tabs component, which will give us a location prop with the current path. We can then use this with React Router’s matchPath function to check which of our items, and therefore, which of our tabs, is currently active.

import {
  Switch,
  Route,
  Redirect,
  matchPath,
  useLocation,
} from "react-router-dom"

const Tabs = ({ items }) => {
  const location = useLocation()

  // Find currently active item by checking
  // which tab route "matches" the current path
  const active = items.find(item =>
    matchPath(location.pathname, {
      path: `/${item.route}`,
      exact: true,
    })
  )

  // Safely access the route of the active item
  const activeRoute = active && active.route

  return (
    <React.Fragment>
      <ul role="tablist" aria-orientation="horizontal" className="tabs-list">
        {items.map(item => (
          <li className="tabs-list__item" key={`tab-${item.route}`}>
            <Link
              to={item.route}
              // Add active class to tab button
              className={`tabs-list__tab ${
                activeRoute === item.route ? "active" : ""
              }`}
            >
              {item.name}
            </Link>
          </li>
        ))}
      </ul>
      ...
    </React.Fragment>
  )
}

export default Tabs

With that addition, we can now use some CSS to apply a green underline to our active tab, using a pseudo-element.

/* A psuedo-element to use as an active underline */
.tabs-list__tab:after {
  content: "";
  position: absolute;
  bottom: -1px;
  left: 0;
  height: 3px;
  width: 100%;
}

.tabs-list__tab.active:after {
  background-color: green;
}

At this point, we now have a tab component which wouldn’t feel out of place in the swankiest of websites.

But wait, I promised you animation, and that solemn oath must be honoured.

Framer Motion is a really nice animation library for React, which makes complex animation super simple. The underline animation we’ll be attempting today is surprisingly difficult to pull off with just regular ol’ CSS, but becomes quite easy with Framer, so let’s go ahead and install it:

npm install framer-motion

Before we get stuck into the animating underline, we will need to lay some groundwork for our animations. We’ll be using a simple animating state variable to keep track of whether we’re animating or not, as this information will become very important to us in a moment.

const [animating, setAnimating] = React.useState(false)

We’ll also be creating an array of React refs for our tab buttons. These refs will let us access the DOM elements for the tabs, as we are going to need to know the left offset and width of the active tab, so that we can animate our underline from the previously active tab.

const tabRefs = items.reduce((acc, item) => {
  acc[item.route] = React.createRef()
  return acc
}, {})

Now let’s create a new component for our animated underline:

import React from "react"
import PropTypes from "prop-types"
import { motion } from "framer-motion"
import debounce from "../utils/debounce"

const Underline = ({ refs, activeRoute, finishAnimating, animating }) => {
  // We keep the x offset and width in state
  const [{ x, width }, setAttributes] = React.useState({
    x: 0,
    width: 0,
  })

  // A memoised function to update the state using the active element
  const updateAttributes = React.useCallback(() => {
    if (refs && refs[activeRoute]) {
      setAttributes({
        x: refs[activeRoute].current.offsetLeft,
        width: refs[activeRoute].current.getBoundingClientRect().width,
      })
    }
  }, [activeRoute, refs])

  // If active route changes (or refs change), recalculate attributes
  React.useEffect(() => {
    updateAttributes()
  }, [activeRoute, refs, updateAttributes])

  // After window resize, recalculate attributes
  React.useEffect(() => {
    // We can debounce the window events, for improved performance
    const recalculateAttrs = debounce(() => {
      updateAttributes()
    }, 500)

    window.addEventListener("resize", recalculateAttrs)
    return () => {
      window.removeEventListener("resize", recalculateAttrs)
    }
  })

  return (
    <motion.div
      className="tabs-list__underline"
      // Animate the x offset and width
      animate={{
        x,
        width,
      }}
      // Only show this when animating
      style={{
        opacity: animating ? 1 : 0,
      }}
      // Let the parent (Tabs) know when animation completes
      onAnimationComplete={finishAnimating}
    />
  )
}

export default Underline

Putting the pieces together, we have something like this in our Tabs file now.

import React from "react"
import {
  Switch,
  Route,
  Redirect,
  matchPath,
  useLocation,
} from "react-router-dom"
import Tab from "./tab"
import Underline from "./underline"

const Tabs = ({ items }) => {
  const [animating, setAnimating] = React.useState(false)

  const location = useLocation()

  const tabRefs = items.reduce((acc, item) => {
    acc[item.id] = React.createRef()
    return acc
  }, {})

  const active = items.find(item =>
    matchPath(location.pathname, {
      path: `/${item.route}`,
      exact: true,
    })
  )

  const activeRoute = active && active.id

  return (
    <React.Fragment>
      {/* A new tabs wrapper with position: relative to position the underline to */}
      <div className="tabs">
        <ul role="tablist" aria-orientation="horizontal" className="tabs-list">
          {items.map(item => (
            <Tab
              key={item.id}
              location={location}
              item={item}
              ref={tabRefs[item.id]}
              active={activeRoute === item.id}
              // Set animating to true when clicking a tab
              startAnimating={() => setAnimating(true)}
              animating={animating}
            />
          ))}
        </ul>
        <Underline
          activeTabRef={tabRefs[activeRoute]}
          // Set animating to false when the animation has finished
          finishAnimating={() => setAnimating(false)}
          animating={animating}
        />
      </div>
      <Switch>
        {items.map(item => (
          <Route key={item.id} path={`/${item.route}`} render={item.render} />
        ))}
        <Route
          render={() => <Redirect to={items[0] ? items[0].route : "/"} />}
        />
      </Switch>
    </React.Fragment>
  )
}

export default Tabs

And we also need to add some CSS to style our animated underline:

/* Tabs wrapper */
.tabs {
  position: relative;
}

/* Animated underline */
.tabs-list__underline {
  position: absolute;
  bottom: 0;
  left: 0; // This value will be updated by Framer Motion
  width: 0; // This value will be updated by Framer Motion
  height: 3px;
  background-color: green;
}

We are setting the width and left properties of the underline, based on the size of the tab at the point we clicked/tapped it. But, as always with the responsive web, we cannot be sure that the elements will remain at this size - if the user changes the screen size, it is possible that the underline will not be correctly positioned any more!

We can add a event listener to the window which fires when we resize the screen, which will recalculate the attributes. I’ve chosen to debounce this event, so only one event fires per window resize.

// After window resize, recalculate left offset and width
React.useEffect(() => {
  // We can debounce the event for performance
  const recalculateAttrs = debounce(() => {
    if (refs && refs[activeRoute]) {
      setAttrs({
        left: refs[activeRoute].current.offsetLeft,
        width: refs[activeRoute].current.getBoundingClientRect().width,
      })
    }
  }, 500)

  window.addEventListener("resize", recalculateAttrs)
  return () => {
    window.removeEventListener("resize", recalculateAttrs)
  }
})

We could let our animated line move around, but another option is to simply hide the animated underline, and let our old psuedo element active state take it’s place. This is just a slight improvement in the experience of the tabs in my opinion — when a user isn’t interacting with the tabs, they’ll remain completely static (rather than floating around when the screen size changes, as we wait for the window resize event to update the attributes).

/* Hide static active underline while animating */
.tabs-list__tab.active.animating:after {
  background-color: transparent;
}

One small addition is to add some kind of transition effect for the tab contents. Why should they miss out on all this animated fun?

This is really easy for us to add using Framer Motion, as we can use the AnimatePresence component to apply an animation as different routes are matched in our Switch.

We can add the exitBeforeEnter prop to AnimatePresence to simplify things - this will finish animating out the leaving route before animating in the new one. Here I’ve applied a super simple fade in/out animation to the proceedings:

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

...

<AnimatePresence exitBeforeEnter>
  <Switch location={location} key={location.pathname}>
    {items.map((item) => (
      <Route
        key={item.route}
        path={`/${item.route}`}
        render={() => (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          >
            {item.render()}
          </motion.div>
        )}
      />
    ))}
    {/*
      Need to wrap the redirect in a motion component with an "exit" defined
      https://www.framer.com/api/motion/animate-presence/#animating-custom-components
    */}
    <Route
      key="redirection"
      render={() => (
        <motion.div exit={{ opacity: 0 }}>
          <Redirect to={items[0] ? `/${items[0].route}` : '/'} />
        </motion.div>
      )}
    />
  </Switch>
</AnimatePresence>

And with that little change, we now have a fully functional animated tab component!

Obviously this is quite a simple example, but it’s a good base. We can easily start changing the styles and animation settings to get something cool like this:

You can peruse the code here:

© 2021