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