Back to React Spectrum

Swipeable Tabs

packages/react-aria-components/docs/examples/swipeable-tabs.mdx

2022-12-168.5 KB
Original Source

{/* 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';


keywords: [example, tabs, aria, accessibility, react, component] type: component image: swipeable-tabs.png description: A swipeable Tabs component built with Framer Motion and CSS scroll snapping.

Swipeable Tabs

A swipeable Tabs component built with with React Aria Components, Framer Motion, Tailwind CSS, and CSS scroll snapping.

Example

tsx
import './tailwind.global.css';
css
.no-scrollbar::-webkit-scrollbar {
  display: none;
}

.no-scrollbar {
  scrollbar-width: none;
}
tsx
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>
  );
}

Tailwind config

This example uses the tailwindcss-react-aria-components plugin. When using Tailwind v4, add it to your CSS:

css
@import "tailwindcss";
@plugin "tailwindcss-react-aria-components";
<details> <summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Tailwind v3</summary>

When using Tailwind v3, add the plugin to your tailwind.config.js instead:

tsx
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.

</details>

Components

<section className={styles.cardGroup} data-size="small">

<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>

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!

<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>