React AriaExamples

Swipeable Tabs

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

Example#


World contents...

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.

N.Y. contents...

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.

Business contents...

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.

Arts contents...

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.

Science contents...

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.

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!