Swipeable Tabs
A swipeable Tabs component built with with React Aria Components, Framer Motion, Tailwind CSS, and CSS scroll snapping.
Example#
import {Collection, Tab, TabList, TabPanel, Tabs} from 'react-aria-components';
import {animate, motion, useMotionValueEvent, useScroll, useTransform} from 'framer-motion';
import {useCallback, useEffect, useRef, useState} from 'react';
let tabs = [
{ id: 'world', label: 'World' },
{ id: 'ny', label: 'N.Y.' },
{ id: 'business', label: 'Business' },
{ id: 'arts', label: 'Arts' },
{ id: 'science', label: 'Science' }
];
function AnimatedTabs() {
let [selectedKey, setSelectedKey] = useState(tabs[0].id);
let tabListRef = useRef(null);
let tabPanelsRef = useRef(null);
// Track the scroll position of the tab panel container.
let { scrollXProgress } = useScroll({
container: tabPanelsRef
});
// Find all the tab elements so we can use their dimensions.
let [tabElements, setTabElements] = useState([]);
useEffect(() => {
if (tabElements.length === 0) {
let tabs = tabListRef.current.querySelectorAll('[role=tab]');
setTabElements(tabs);
}
}, [tabElements]);
// This function determines which tab should be selected
// based on the scroll position.
let getIndex = useCallback(
(x) => Math.max(0, Math.floor((tabElements.length - 1) * x)),
[tabElements]
);
// This function transforms the scroll position into the X position
// or width of the selected tab indicator.
let transform = (x, property) => {
if (!tabElements.length) return 0;
// Find the tab index for the scroll X position.
let index = getIndex(x);
// Get the difference between this tab and the next one.
let difference = index < tabElements.length - 1
? tabElements[index + 1][property] - tabElements[index][property]
: tabElements[index].offsetWidth;
// Get the percentage between tabs.
// This is the difference between the integer index and fractional one.
let percent = (tabElements.length - 1) * x - index;
// Linearly interpolate to calculate the position of the selection indicator.
let value = tabElements[index][property] + difference * percent;
// iOS scrolls weird when translateX is 0 for some reason. 🤷♂️
return value || 0.1;
};
let x = useTransform(scrollXProgress, (x) => transform(x, 'offsetLeft'));
let width = useTransform(scrollXProgress, (x) => transform(x, 'offsetWidth'));
// When the user scrolls, update the selected key
// so that the correct tab panel becomes interactive.
useMotionValueEvent(scrollXProgress, 'change', (x) => {
if (animationRef.current || !tabElements.length) return;
setSelectedKey(tabs[getIndex(x)].id);
});
// When the user clicks on a tab perform an animation of
// the scroll position to the newly selected tab panel.
let animationRef = useRef(null);
let onSelectionChange = (selectedKey) => {
setSelectedKey(selectedKey);
// If the scroll position is already moving but we aren't animating
// then the key changed as a result of a user scrolling. Ignore.
if (scrollXProgress.getVelocity() && !animationRef.current) {
return;
}
let tabPanel = tabPanelsRef.current;
let index = tabs.findIndex((tab) => tab.id === selectedKey);
animationRef.current?.stop();
animationRef.current = animate(
tabPanel.scrollLeft,
tabPanel.scrollWidth * (index / tabs.length),
{
type: 'spring',
bounce: 0.2,
duration: 0.6,
onUpdate: (v) => {
tabPanel.scrollLeft = v;
},
onPlay: () => {
// Disable scroll snap while the animation is going or weird things happen.
tabPanel.style.scrollSnapType = 'none';
},
onComplete: () => {
tabPanel.style.scrollSnapType = '';
animationRef.current = null;
}
}
);
};
return (
<Tabs
className="w-fit max-w-[min(100%,350px)]"
selectedKey={selectedKey}
onSelectionChange={onSelectionChange}
>
<div className="relative">
<TabList ref={tabListRef} className="flex -mx-1" items={tabs}>
{(tab) => (
<Tab className="cursor-default px-3 py-1.5 text-md transition outline-none touch-none">
{({ isSelected, isFocusVisible }) => (
<>
{tab.label}
{isFocusVisible && isSelected && (
// Focus ring.
<motion.span
className="absolute inset-0 z-10 rounded-full ring-2 ring-black ring-offset-2"
style={{ x, width }}
/>
)}
</>
)}
</Tab>
)}
</TabList>
{/* Selection indicator. */}
<motion.span
className="absolute inset-0 z-10 bg-white rounded-full mix-blend-difference"
style={{ x, width }}
/>
</div>
<div
ref={tabPanelsRef}
className="my-4 overflow-auto snap-x snap-mandatory no-scrollbar flex"
>
<Collection items={tabs}>
{(tab) => (
<TabPanel
shouldForceMount
className="flex-shrink-0 w-full px-2 box-border snap-start outline-none -outline-offset-2 rounded focus-visible:outline-black"
>
<h2>{tab.label} contents...</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean
sit amet nisl blandit, pellentesque eros eu, scelerisque eros.
Sed cursus urna at nunc lacinia dapibus.
</p>
</TabPanel>
)}
</Collection>
</div>
</Tabs>
);
}
import {
Collection,
Tab,
TabList,
TabPanel,
Tabs
} from 'react-aria-components';
import {
animate,
motion,
useMotionValueEvent,
useScroll,
useTransform
} from 'framer-motion';
import {
useCallback,
useEffect,
useRef,
useState
} from 'react';
let tabs = [
{ id: 'world', label: 'World' },
{ id: 'ny', label: 'N.Y.' },
{ id: 'business', label: 'Business' },
{ id: 'arts', label: 'Arts' },
{ id: 'science', label: 'Science' }
];
function AnimatedTabs() {
let [selectedKey, setSelectedKey] = useState(tabs[0].id);
let tabListRef = useRef(null);
let tabPanelsRef = useRef(null);
// Track the scroll position of the tab panel container.
let { scrollXProgress } = useScroll({
container: tabPanelsRef
});
// Find all the tab elements so we can use their dimensions.
let [tabElements, setTabElements] = useState([]);
useEffect(() => {
if (tabElements.length === 0) {
let tabs = tabListRef.current.querySelectorAll(
'[role=tab]'
);
setTabElements(tabs);
}
}, [tabElements]);
// This function determines which tab should be selected
// based on the scroll position.
let getIndex = useCallback(
(x) =>
Math.max(0, Math.floor((tabElements.length - 1) * x)),
[tabElements]
);
// This function transforms the scroll position into the X position
// or width of the selected tab indicator.
let transform = (x, property) => {
if (!tabElements.length) return 0;
// Find the tab index for the scroll X position.
let index = getIndex(x);
// Get the difference between this tab and the next one.
let difference = index < tabElements.length - 1
? tabElements[index + 1][property] -
tabElements[index][property]
: tabElements[index].offsetWidth;
// Get the percentage between tabs.
// This is the difference between the integer index and fractional one.
let percent = (tabElements.length - 1) * x - index;
// Linearly interpolate to calculate the position of the selection indicator.
let value = tabElements[index][property] +
difference * percent;
// iOS scrolls weird when translateX is 0 for some reason. 🤷♂️
return value || 0.1;
};
let x = useTransform(
scrollXProgress,
(x) => transform(x, 'offsetLeft')
);
let width = useTransform(
scrollXProgress,
(x) => transform(x, 'offsetWidth')
);
// When the user scrolls, update the selected key
// so that the correct tab panel becomes interactive.
useMotionValueEvent(scrollXProgress, 'change', (x) => {
if (animationRef.current || !tabElements.length) return;
setSelectedKey(tabs[getIndex(x)].id);
});
// When the user clicks on a tab perform an animation of
// the scroll position to the newly selected tab panel.
let animationRef = useRef(null);
let onSelectionChange = (selectedKey) => {
setSelectedKey(selectedKey);
// If the scroll position is already moving but we aren't animating
// then the key changed as a result of a user scrolling. Ignore.
if (
scrollXProgress.getVelocity() && !animationRef.current
) {
return;
}
let tabPanel = tabPanelsRef.current;
let index = tabs.findIndex((tab) =>
tab.id === selectedKey
);
animationRef.current?.stop();
animationRef.current = animate(
tabPanel.scrollLeft,
tabPanel.scrollWidth * (index / tabs.length),
{
type: 'spring',
bounce: 0.2,
duration: 0.6,
onUpdate: (v) => {
tabPanel.scrollLeft = v;
},
onPlay: () => {
// Disable scroll snap while the animation is going or weird things happen.
tabPanel.style.scrollSnapType = 'none';
},
onComplete: () => {
tabPanel.style.scrollSnapType = '';
animationRef.current = null;
}
}
);
};
return (
<Tabs
className="w-fit max-w-[min(100%,350px)]"
selectedKey={selectedKey}
onSelectionChange={onSelectionChange}
>
<div className="relative">
<TabList
ref={tabListRef}
className="flex -mx-1"
items={tabs}
>
{(tab) => (
<Tab className="cursor-default px-3 py-1.5 text-md transition outline-none touch-none">
{({ isSelected, isFocusVisible }) => (
<>
{tab.label}
{isFocusVisible && isSelected && (
// Focus ring.
<motion.span
className="absolute inset-0 z-10 rounded-full ring-2 ring-black ring-offset-2"
style={{ x, width }}
/>
)}
</>
)}
</Tab>
)}
</TabList>
{/* Selection indicator. */}
<motion.span
className="absolute inset-0 z-10 bg-white rounded-full mix-blend-difference"
style={{ x, width }}
/>
</div>
<div
ref={tabPanelsRef}
className="my-4 overflow-auto snap-x snap-mandatory no-scrollbar flex"
>
<Collection items={tabs}>
{(tab) => (
<TabPanel
shouldForceMount
className="flex-shrink-0 w-full px-2 box-border snap-start outline-none -outline-offset-2 rounded focus-visible:outline-black"
>
<h2>{tab.label} contents...</h2>
<p>
Lorem ipsum dolor sit amet, consectetur
adipiscing elit. Aenean sit amet nisl
blandit, pellentesque eros eu, scelerisque
eros. Sed cursus urna at nunc lacinia
dapibus.
</p>
</TabPanel>
)}
</Collection>
</div>
</Tabs>
);
}
import {
Collection,
Tab,
TabList,
TabPanel,
Tabs
} from 'react-aria-components';
import {
animate,
motion,
useMotionValueEvent,
useScroll,
useTransform
} from 'framer-motion';
import {
useCallback,
useEffect,
useRef,
useState
} from 'react';
let tabs = [
{
id: 'world',
label: 'World'
},
{
id: 'ny',
label: 'N.Y.'
},
{
id: 'business',
label: 'Business'
},
{
id: 'arts',
label: 'Arts'
},
{
id: 'science',
label: 'Science'
}
];
function AnimatedTabs() {
let [
selectedKey,
setSelectedKey
] = useState(
tabs[0].id
);
let tabListRef =
useRef(null);
let tabPanelsRef =
useRef(null);
// Track the scroll position of the tab panel container.
let {
scrollXProgress
} = useScroll({
container:
tabPanelsRef
});
// Find all the tab elements so we can use their dimensions.
let [
tabElements,
setTabElements
] = useState([]);
useEffect(() => {
if (
tabElements
.length === 0
) {
let tabs =
tabListRef
.current
.querySelectorAll(
'[role=tab]'
);
setTabElements(
tabs
);
}
}, [tabElements]);
// This function determines which tab should be selected
// based on the scroll position.
let getIndex =
useCallback(
(x) =>
Math.max(
0,
Math.floor(
(tabElements
.length -
1) * x
)
),
[tabElements]
);
// This function transforms the scroll position into the X position
// or width of the selected tab indicator.
let transform = (
x,
property
) => {
if (
!tabElements.length
) {
return 0;
}
// Find the tab index for the scroll X position.
let index = getIndex(
x
);
// Get the difference between this tab and the next one.
let difference =
index <
tabElements
.length - 1
? tabElements[
index + 1
][property] -
tabElements[
index
][property]
: tabElements[
index
].offsetWidth;
// Get the percentage between tabs.
// This is the difference between the integer index and fractional one.
let percent =
(tabElements
.length - 1) *
x - index;
// Linearly interpolate to calculate the position of the selection indicator.
let value =
tabElements[index][
property
] +
difference *
percent;
// iOS scrolls weird when translateX is 0 for some reason. 🤷♂️
return value || 0.1;
};
let x = useTransform(
scrollXProgress,
(x) =>
transform(
x,
'offsetLeft'
)
);
let width =
useTransform(
scrollXProgress,
(x) =>
transform(
x,
'offsetWidth'
)
);
// When the user scrolls, update the selected key
// so that the correct tab panel becomes interactive.
useMotionValueEvent(
scrollXProgress,
'change',
(x) => {
if (
animationRef
.current ||
!tabElements
.length
) {
return;
}
setSelectedKey(
tabs[getIndex(x)]
.id
);
}
);
// When the user clicks on a tab perform an animation of
// the scroll position to the newly selected tab panel.
let animationRef =
useRef(null);
let onSelectionChange =
(selectedKey) => {
setSelectedKey(
selectedKey
);
// If the scroll position is already moving but we aren't animating
// then the key changed as a result of a user scrolling. Ignore.
if (
scrollXProgress
.getVelocity() &&
!animationRef
.current
) {
return;
}
let tabPanel =
tabPanelsRef
.current;
let index = tabs
.findIndex((
tab
) =>
tab.id ===
selectedKey
);
animationRef
.current?.stop();
animationRef
.current =
animate(
tabPanel
.scrollLeft,
tabPanel
.scrollWidth *
(index /
tabs
.length),
{
type:
'spring',
bounce:
0.2,
duration:
0.6,
onUpdate: (
v
) => {
tabPanel
.scrollLeft =
v;
},
onPlay:
() => {
// Disable scroll snap while the animation is going or weird things happen.
tabPanel
.style
.scrollSnapType =
'none';
},
onComplete:
() => {
tabPanel
.style
.scrollSnapType =
'';
animationRef
.current =
null;
}
}
);
};
return (
<Tabs
className="w-fit max-w-[min(100%,350px)]"
selectedKey={selectedKey}
onSelectionChange={onSelectionChange}
>
<div className="relative">
<TabList
ref={tabListRef}
className="flex -mx-1"
items={tabs}
>
{(tab) => (
<Tab className="cursor-default px-3 py-1.5 text-md transition outline-none touch-none">
{(
{
isSelected,
isFocusVisible
}
) => (
<>
{tab
.label}
{isFocusVisible &&
isSelected &&
(
// Focus ring.
<motion.span
className="absolute inset-0 z-10 rounded-full ring-2 ring-black ring-offset-2"
style={{
x,
width
}}
/>
)}
</>
)}
</Tab>
)}
</TabList>
{/* Selection indicator. */}
<motion.span
className="absolute inset-0 z-10 bg-white rounded-full mix-blend-difference"
style={{
x,
width
}}
/>
</div>
<div
ref={tabPanelsRef}
className="my-4 overflow-auto snap-x snap-mandatory no-scrollbar flex"
>
<Collection
items={tabs}
>
{(tab) => (
<TabPanel
shouldForceMount
className="flex-shrink-0 w-full px-2 box-border snap-start outline-none -outline-offset-2 rounded focus-visible:outline-black"
>
<h2>
{tab
.label}
{' '}
contents...
</h2>
<p>
Lorem
ipsum
dolor sit
amet,
consectetur
adipiscing
elit.
Aenean
sit amet
nisl
blandit,
pellentesque
eros eu,
scelerisque
eros. Sed
cursus
urna at
nunc
lacinia
dapibus.
</p>
</TabPanel>
)}
</Collection>
</div>
</Tabs>
);
}
Tailwind config#
This example uses the tailwindcss-react-aria-components plugin. Add it to your tailwind.config.js
:
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
module.exports = {
// ...
plugins: [
require(
'tailwindcss-react-aria-components'
)
]
};
Components#
Tabs
Tabs organize content into multiple sections, and allow a user to view one at a time.
Learn more#
This example was inspired by Sam Selikoff's video "Animated tabs with inverted text". Check it out to learn more about how the inverted text effect works!