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.
Location
Lorem ipsum dolor sit amet consectetur.
Forms
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:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| tabs | { id: string; label: React.ReactNode; content: React.ReactNode }[] | Yes | — | Array of tab definitions (e.g. { id: "home", label: "Home", content: <h1>Home</h1> }). |
| activeTab | string | No | — | Controlled active tab id. When provided, the component works in controlled mode. |
| defaultTab | string | No | First tab | Initial active tab id when used in uncontrolled mode. |
| onTabChange | (id: string) => void | No | — | Callback fired when the active tab changes. |
| renderOnlyActiveTabPanel | boolean | No | false | If true, only the active tab panel is mounted in the DOM. |