packages/react-aria-components/docs/examples/swipeable-tabs.mdx
{/* Copyright 2023 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */}
import {ExampleLayout} from '@react-spectrum/docs'; export default ExampleLayout;
import docs from 'docs:react-aria-components'; import {TypeLink} from '@react-spectrum/docs'; import styles from '@react-spectrum/docs/src/docs.css'; import Tabs from '@react-spectrum/docs/pages/assets/component-illustrations/Tabs.svg'; import {ExampleCard} from '@react-spectrum/docs/src/ExampleCard'; import ChevronRight from '@spectrum-icons/workflow/ChevronRight';
A swipeable Tabs component built with with React Aria Components, Framer Motion, Tailwind CSS, and CSS scroll snapping.
import './tailwind.global.css';
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
scrollbar-width: none;
}
import {
Tabs,
TabList,
Tab,
TabPanel,
Collection
} from "react-aria-components";
import { motion, animate, useScroll, useTransform, useMotionValueEvent } from "motion/react";
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-hidden 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>
<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="shrink-0 w-full px-2 box-border snap-start outline-hidden -outline-offset-2 rounded-sm 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>
);
}
This example uses the tailwindcss-react-aria-components plugin. When using Tailwind v4, add it to your CSS:
@import "tailwindcss";
@plugin "tailwindcss-react-aria-components";
When using Tailwind v3, add the plugin to your tailwind.config.js instead:
module.exports = {
// ...
plugins: [
require('tailwindcss-react-aria-components')
]
};
Note: When using Tailwind v3, install tailwindcss-react-aria-components version 1.x instead of 2.x.
<ExampleCard url="../Tabs.html" title="Tabs" description="Tabs organize content into multiple sections, and allow a user to view one at a time."> <Tabs /> </ExampleCard>
</section>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!
<div className={styles.responsiveVideo}> <iframe title="YouTube embedded video: 'Animated tabs – with inverted text!' by Sam Selikoff" width="840" height="471" src="https://www.youtube.com/embed/kep_Iaxuzy0" frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen /> </div>