<div id="root"></div>
body {
  background-color: #5B37B7;
  padding: 20px;
  margin: 0;
  font-family: Helvetica, Arial, sans-serif;
  transition: background-color 0.5s ease;
}

@media (min-width: 600px) {
  body {
    padding: 30px;
  }
}

ul, li {
  list-style: none;
  padding: 0;
  margin: 0;
}

/* Tabs */
.tabs-wrapper {
  margin: 0 auto;
  max-width: 1000px;
  padding: 20px;
  border-radius: 20px;
  background: #FFF;
}

@media (min-width: 600px) {
  .tabs-wrapper {
    padding: 40px;
  }
}

.tabs {
  display: block;
  position: relative;
}

.tabs-list {
  display: flex;
}

.tabs-list__item {
  flex: 1 1 auto;
}

@media (min-width: 600px) {
  .tabs-list__item {
    flex: initial;
  }
}

.tabs-list__tab {
  text-align: center;
  display: block;
  line-height: 60px;
  padding: 0 10px;
  background: transparent;
  color: #272727;
  position: relative;
  text-decoration: none;
  font-weight: bold;
  white-space: nowrap;
  font-size: 14px;
}

@media (min-width: 600px) {
  .tabs-list__tab {
    font-size: 16px;
    padding: 0 20px;
  }
}

.tabs-list__tab span {
  position: relative;
  color: #272727;
  transition: color 600ms cubic-bezier(0.4, 0, 0.2, 1);
}

.tabs-list__tab:before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 100%;
  border-radius: 30px;
}

.tabs-list__tab:hover {
}

.tabs-list__tab.active {
}

.tabs-list__tab.active:before {
  background-color: currentColor;
}

.tabs-list__tab.active.animating:before {
  background-color: transparent;
}

.tabs-list__active {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  background-color: #dacff4;
  border-radius: 30px;
  transition: background-color 600ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Tab Content */
.tab-content {
  background: #FFFFFF;
  padding: 10px 0 0 0;
}

.tab-content p {
  margin: 0;
  margin-top: 20px;
  font-size: 16px;
  line-height: 1.6;
}
const { HashRouter, Switch, Route, Redirect, Link, matchPath, useLocation } = ReactRouterDOM;
const { AnimatePresence, motion } = Motion;

const debounce = (func, wait, immediate) => {
	var timeout;
	return function() {
		var context = this, args = arguments;
		var later = function() {
			timeout = null;
			if (!immediate) func.apply(context, args);
		};
		var callNow = immediate && !timeout;
		clearTimeout(timeout);
		timeout = setTimeout(later, wait);
		if (callNow) func.apply(context, args);
	};
};

const Active = ({ refs, activeRoute, finishAnimating, animating }) => {
  const [{ x, width, color }, setAttributes] = React.useState({
    x: 0,
    width: 0,
    color: '#272727',
  });

  const updateAttributes = React.useCallback(() => {
    if (refs && refs[activeRoute]) {
      console.log(refs[activeRoute].current.style)
      setAttributes({
        x: refs[activeRoute].current.offsetLeft,
        width: refs[activeRoute].current.getBoundingClientRect().width,
        color: refs[activeRoute].current.style.color,
      });
    }
  }, [activeRoute, refs])

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

  // After window resize, recalculate
  React.useEffect(() => {
    const recalculateAttrs = debounce(() => {
      updateAttributes();
    }, 500);

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

  return (
    <motion.div
      className="tabs-list__active"
      animate={{
        x,
        width,
      }}
      style={{
        opacity: animating ? 1 : 0,
        backgroundColor: color,
      }}
      onAnimationComplete={finishAnimating}
    />
  );
};

const Tab = React.forwardRef(
  ({ active, item, animating, startAnimating }, ref) => (
    <li 
      className="tabs-list__item" 
      key={`tab-${item.route}`}
     >
      <Link
        to={item.route}
        className={`tabs-list__tab ${active ? 'active' : 'inactive'} ${animating ? 'animating' : ''}`}
        ref={ref}
        onClick={startAnimating}
        style={{ color: item.colour }}
      >
        <span style={{
          color: active ? item.bgColour : '#272727'
        }}>{item.name}</span>
      </Link>
    </li>
  ),
);

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

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

  const location = useLocation();

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

  const activeRoute = active && active.route;
  
  // Update bg colour on route change
  React.useEffect(() => {
    const activeColour = active && active.bgColour;

    if (document && document.body) {
      document.body.style.backgroundColor = activeColour;
    }
  }, [activeRoute]);

  return (
    <div className="tabs-wrapper">
      <div className="tabs">
        <Active
          refs={tabRefs}
          activeRoute={activeRoute}
          finishAnimating={() => setAnimating(false)}
          animating={animating}
        />
        <ul role="tablist" aria-orientation="horizontal" className="tabs-list">
          {items.map((item) => (
            <Tab
              key={item.route}
              location={location}
              item={item}
              ref={tabRefs[item.route]}
              active={activeRoute === item.route}
              animating={animating}
              startAnimating={() => setAnimating(true)}
            />
          ))}
        </ul>
      </div>
      <motion.div className="tab-content" layoutTransition>
        <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>
      </motion.div>
    </div>
  );
};

function App() {
  return (
    <HashRouter>
      <Tabs items={[
          {
            name: 'Tab #1',
            route: 'id1',
            bgColour: '#5B37B7',
            colour: '#DFD7F3',
            render: () => (
              <>
                <p>Depths burying snare value law merciful value snare society eternal-return decieve aversion. Holiest virtues pious war depths noble inexpedient against endless ultimate.</p>
                <p>Merciful disgust convictions grandeur abstract battle gains revaluation fearful inexpedient right holiest faithful battle. Merciful depths decrepit intentions virtues salvation war ultimate. Sea transvaluation virtues suicide battle against victorious.</p>
                <p>Ocean burying depths evil horror suicide mountains fearful depths christianity disgust gains horror. Self marvelous passion faith against grandeur.</p>
              </>
            )
          },
          {
            name: 'Tab #2',
            route: 'id2',
            bgColour: '#C9379D',
            colour: '#F7D7EF',
            render: () => (
              <>
                <p>Ideal overcome free burying grandeur aversion. Dead morality self right superiority passion virtues hope society play of snare grandeur. Good oneself burying law good ultimate burying.</p>
                <p>Play justice snare holiest noble sea reason marvelous right.</p>
                <p>Depths burying snare value law merciful value snare society eternal-return decieve aversion. Holiest virtues pious war depths noble inexpedient against endless ultimate.</p>
              </>
            )
          },
          {
            name: 'Tab #3',
            route: 'id3',
            bgColour: '#E6A919',
            colour: '#FBEFD3',
            render: () => (
              <>
                <p>Inexpedient gains prejudice aversion pious snare noble ocean ocean overcome self ubermensch prejudice philosophy. Ocean strong sea burying reason ultimate burying spirit. Pious christianity decieve endless abstract decrepit abstract.</p>
                <p>Depths burying snare value law merciful value snare society eternal-return decieve aversion. Holiest virtues pious war depths noble inexpedient against endless ultimate.</p>
              </>
            )
          },
          {
            name: 'Tab #4',
            route: 'id4',
            bgColour: '#1194AA',
            colour: '#D1EBEF',
            render: () => (
              <>
                <p>Depths burying snare value law merciful value snare society eternal-return decieve aversion. Holiest virtues pious war depths noble inexpedient against endless ultimate.</p>
                <p>Ocean burying depths evil horror suicide mountains fearful depths christianity disgust gains horror. Self marvelous passion faith against grandeur.</p>
              </>
            )
          }
        ]} />
    </HashRouter>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://unpkg.com/react@16.10.2/umd/react.development.js
  2. https://unpkg.com/react-dom@16.10.2/umd/react-dom.development.js
  3. https://unpkg.com/framer-motion@1.6.7/dist/framer-motion.js
  4. https://unpkg.com/react-router-dom@5.1.2/umd/react-router-dom.js