Floating Indicator Tabs

A lightweight accessible tab component designed with a fluid motion indicator that delivers smooth, spring-based transitions.

Home

Lorem ipsum dolor sit amet consectetur.

1. Copy the component file

Create a new file called floating-indicator-tabs.jsx in your reusable components folder (for example /src/components/) and paste the following code into it.

floating-indicator-tabs.jsx

import { useState, useRef, useLayoutEffect, memo } from "react";
import styles from "./floating-indicator-tabs.module.css";

const FloatingIndicatorTabs = (props) => {
  const {
    tabs,
    activeTab,
    defaultTab,
    onTabChange,
    renderOnlyActiveTab = false,
  } = props;

  const isControlled = activeTab !== undefined;
  const tabsRef = useRef([]);
  const indicatorRef = useRef(null);

  const [mounted, setMounted] = useState(false);
  const [currentTab, setCurrentTab] = useState(() => {
    if (isControlled) return;
    if (defaultTab) return defaultTab;
    return tabs?.[0].id;
  });

  const selectedTab = isControlled ? activeTab : currentTab;
  
  const handleTabClick = (tab) => {
    if(!isControlled) {
      setCurrentTab(tab.id);
    }
    onTabChange?.(tab.id);
  };

  const handleKeyDown = (e, index) => {
    let newIndex = index;
    switch (e.key) {
      case "ArrowRight":
        newIndex = (index + 1) % tabs.length;
        break;
      case "ArrowLeft":
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case "Home":
        newIndex = 0;
        break;
      case "End":
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    const nextTabId = tabs[newIndex].id;
    if(!isControlled) {
      setCurrentTab(nextTabId);
    }
    onTabChange?.(nextTabId);    
  };

  useLayoutEffect(() => {
    if(!mounted) {
      setMounted(true);
    }
    const selectedTabIndex = tabs.findIndex(tab => tab.id === selectedTab);
    if (selectedTabIndex < 0) {
      return;
    }
    const selectedTabRef = tabsRef.current[selectedTabIndex];
    const { offsetLeft, offsetWidth } = selectedTabRef;
    indicatorRef.current.style.left = `${offsetLeft}px`;
    indicatorRef.current.style.width = `${offsetWidth}px`;
    if(mounted) {
      selectedTabRef.focus();
    }
  }, [selectedTab]);

  return (
    <div className={styles["floating-indicator-tabs"]}>
      <div
        role="tablist"
      >
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => (tabsRef.current[index] = el)}
            aria-selected={tab.id === selectedTab}
            aria-controls={tab.content ? `${tab.id}-panel`: undefined}
            onClick={() => handleTabClick(tab)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
        <div
          aria-hidden="true"
          data-tab-indicator="true"
          ref={indicatorRef}
        ></div>
      </div>
      {tabs.map((tab) => {
        const isActive = tab.id === selectedTab;
        if (!isActive) {
          if (renderOnlyActiveTab) {
            return null;
          }
        }
        if (!tab.content) {
          return null;
        }
        return (
          <div
            key={`${tab.id}-panel`}
            role="tabpanel"
            aria-labelledby={tab.id}
            id={`${tab.id}-panel`}
            hidden={!isActive}
            className={styles["tab-panel"]}
          >
            {tab.content}
          </div>
        )
      })}
    </div>
  );
};

export default memo(FloatingIndicatorTabs);

2. Copy the CSS module file

In the same folder, create a file called floating-indicator-tabs.module.css and paste the following CSS.

floating-indicator-tabs.module.css

.floating-indicator-tabs {
  --tab-border-color: #ddd;
  --tab-color: #555;
  --tab-active-color: #000;
  --tab-active-indicator-color: #91b5ff;
  --tab-list-gap: 8px;

  @media (prefers-color-scheme: dark) {
    --tab-border-color: #222;
    --tab-color: #bbb;
    --tab-active-color: #fff;
    --tab-active-indicator-color: #3d76ed;
  }

  > [role="tablist"] {
    position: relative;
    width: max-content;
    display: flex;
    gap: var(--tab-list-gap);
    padding: var(--tab-list-gap);
    border: 1px solid var(--tab-border-color);
    border-radius: 8px;

    button {
      padding: 8px 16px;
      font: inherit;
      border: none;
      color: var(--tab-color);
      cursor: pointer;
      z-index: 2;
      background: transparent;

      &[aria-selected="true"],
      &:hover {
        color: var(--tab-active-color);
      }
    }

    > [data-tab-indicator] {
      position: absolute;
      top:50%;
      transform: translateY(-50%);
      width: 0px;
      height: calc(100% - calc(var(--tab-list-gap) * 2));
      background: var(--tab-active-indicator-color);
      border-radius: 6px;
      z-index: 1;
      transition: all 250ms cubic-bezier(0.18, 0.89, 0.32, 1.28);
    }
  }

  > .tab-panel {
    padding-top: 16px;
  }
}

3. Use the component

Now you can import and use the component anywhere in your project.

floating-indicator-tabs-preview.jsx

import FloatingTabs from "@/components/essentials/floating-indicator-tabs/floating-indicator-tabs";

const FloatingTabsPreview = () => {
  const tabs = [
    { id: "home", label: "Home", content: <HomeTab /> },
    { id: "location", label: "Location", content: <LocationTab /> },
    { id: "forms", label: "Forms", content: <FormsTab /> },
  ];

  return (
    <FloatingTabs
      tabs={tabs}
    />
  );
};

const HomeTab = () => {
  return (
    <div>
      <h2>Home</h2>
      <p>Lorem ipsum dolor sit amet consectetur.</p>
    </div>
  );
};

const LocationTab = () => {
  return (
    <div>
      <h2>Location</h2>
      <p>Lorem ipsum dolor sit amet consectetur.</p>
    </div>
  );
};

const FormsTab = () => {
  return (
    <div>
      <h2>Forms</h2>
      <p>Lorem ipsum dolor sit amet consectetur.</p>
    </div>
  );
};

export default FloatingTabsPreview;

Props

Configure the component with the following props:

PropTypeRequiredDefaultDescription
tabs{ id: string; label: React.ReactNode; content: React.ReactNode }[]YesArray of tab definitions (e.g. { id: "home", label: "Home", content: <h1>Home</h1> }).
activeTabstringNoControlled active tab id. When provided, the component works in controlled mode.
defaultTabstringNoFirst tabInitial active tab id when used in uncontrolled mode.
onTabChange(id: string) => voidNoCallback fired when the active tab changes.
renderOnlyActiveTabPanelbooleanNofalseIf true, only the active tab panel is mounted in the DOM.