<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]) { 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
This Pen doesn't use any external CSS resources.