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: