npm install @emmgfx/activity-tabs

activity-tabs

Headless tabs that keep panel state alive when switching.

State preserved

Panel content stays mounted and alive when switching tabs — no state reset, no re-render.

Fully stylable

Pass className and activeClassName to every component. Zero bundled styles.

Headless

No opinions on markup or layout. Compose tabs however your design needs.

TypeScript

Fully typed components and a useActiveTab hook for programmatic control.

Usage

1import {
2 Tabs,
3 TabsList,
4 TabsTab,
5 TabsPanels,
6 TabsPanel,
7} from "@emmgfx/activity-tabs";
8
9<Tabs initialActiveTab="tab1">
10 <TabsList>
11 <TabsTab tabId="tab1" className="..." activeClassName="...">Tab 1</TabsTab>
12 <TabsTab tabId="tab2" className="..." activeClassName="...">Tab 2</TabsTab>
13 </TabsList>
14 <TabsPanels>
15 <TabsPanel tabId="tab1">Content for tab 1</TabsPanel>
16 <TabsPanel tabId="tab2">Content for tab 2</TabsPanel>
17 </TabsPanels>
18</Tabs>

Basic styling with Tailwind

Switch tabs and increment the counter — switch back and the count is still there.

0count
1<Tabs initialActiveTab="counter1" className="rounded-xl border border-(--color-border) overflow-hidden w-full max-w-sm">
2 <TabsList className="flex border-b border-(--color-border)">
3 <TabsTab
4 tabId="counter1"
5 className="flex-1 px-4 py-3 text-sm text-(--color-body) hover:text-(--color-heading) transition-colors border-b-2 border-transparent -mb-px"
6 activeClassName="flex-1 px-4 py-3 text-sm font-medium text-(--color-accent) border-b-2 border-(--color-accent) -mb-px"
7 >
8 First counter
9 </TabsTab>
10 <TabsTab
11 tabId="counter2"
12 className="flex-1 px-4 py-3 text-sm text-(--color-body) hover:text-(--color-heading) transition-colors border-b-2 border-transparent -mb-px"
13 activeClassName="flex-1 px-4 py-3 text-sm font-medium text-(--color-accent) border-b-2 border-(--color-accent) -mb-px"
14 >
15 Second counter
16 </TabsTab>
17 </TabsList>
18 <TabsPanels>
19 <TabsPanel tabId="counter1" className="p-5">
20 <Counter />
21 </TabsPanel>
22 <TabsPanel tabId="counter2" className="p-5">
23 <Counter />
24 </TabsPanel>
25 </TabsPanels>
26</Tabs>

Reusable custom components

Wrap the primitives with your own styles once, reuse everywhere.

1<Tabs initialActiveTab="counter1">
2 <TabsList>
3 <TabsTab tabId="counter1">First counter</TabsTab>
4 <TabsTab tabId="counter2">Second counter</TabsTab>
5 </TabsList>
6 <TabsPanels>
7 <TabsPanel tabId="counter1">
8 <Counter />
9 </TabsPanel>
10 <TabsPanel tabId="counter2">
11 <Counter />
12 </TabsPanel>
13 </TabsPanels>
14</Tabs>
0count

Programmatic control with useActiveTab

Use the hook in any component inside Tabs to read or set the active tab, like these Prev / Next controls.

Apple is a fruit.
1 / 3
1const tabs = [
2 { id: "apple", label: "🍎 Apple", content: "Apple is a fruit." },
3 { id: "banana", label: "🍌 Banana", content: "Banana is a fruit." },
4 { id: "cherry", label: "🍒 Cherry", content: "Cherry is a fruit." },
5];
6
7// Custom tab button — uses the hook directly for active styling
8const TabButton = ({ tabId, children }) => {
9 const [activeTab, setActiveTab] = useActiveTab();
10 const isActive = activeTab === tabId;
11 return (
12 <button onClick={() => setActiveTab(tabId)} className={isActive ? "..." : "..."}>
13 {children}
14 </button>
15 );
16};
17
18// External controls — also uses the hook
19const ExternalControls = () => {
20 const [activeTab, setActiveTab] = useActiveTab();
21 const currentIndex = tabs.findIndex((t) => t.id === activeTab);
22 return (
23 <div>
24 <button onClick={() => setActiveTab(tabs[currentIndex - 1].id)} disabled={currentIndex === 0}>
25 ← Prev
26 </button>
27 <span>{currentIndex + 1} / {tabs.length}</span>
28 <button onClick={() => setActiveTab(tabs[currentIndex + 1].id)} disabled={currentIndex === tabs.length - 1}>
29 Next →
30 </button>
31 </div>
32 );
33};
34
35<Tabs initialActiveTab="apple">
36 <TabsList>
37 {tabs.map((t) => (
38 <TabButton key={t.id} tabId={t.id}>{t.label}</TabButton>
39 ))}
40 </TabsList>
41 <TabsPanels>
42 {tabs.map((t) => (
43 <TabsPanel key={t.id} tabId={t.id}>{t.content}</TabsPanel>
44 ))}
45 </TabsPanels>
46 <ExternalControls />
47</Tabs>

Controlled tabs

Pass activeTab and onActiveTabChange to keep the active tab in your own state. Here it is synced with the URL hash, so every tab has its own link.

A quick look at the product.
state outside Tabs: #overview
1const [activeTab, setActiveTab] = useState("overview");
2
3// URL → tab, on load and when the hash changes (a link, back/forward)
4useEffect(() => {
5 const syncFromHash = () => {
6 const hash = window.location.hash.slice(1);
7 if (tabs.some((t) => t.id === hash)) setActiveTab(hash);
8 };
9 syncFromHash();
10 window.addEventListener("hashchange", syncFromHash);
11 return () => window.removeEventListener("hashchange", syncFromHash);
12}, []);
13
14// Tab → URL, without adding history entries
15const handleChange = (tabId) => {
16 setActiveTab(tabId);
17 window.history.replaceState(null, "", `#${tabId}`);
18};
19
20<Tabs activeTab={activeTab} onActiveTabChange={handleChange}>
21 <TabsList>
22 {tabs.map((t) => (
23 <TabsTab key={t.id} tabId={t.id} className="..." activeClassName="...">
24 {t.label}
25 </TabsTab>
26 ))}
27 </TabsList>
28 <TabsPanels>
29 {tabs.map((t) => (
30 <TabsPanel key={t.id} tabId={t.id}>{t.content}</TabsPanel>
31 ))}
32 </TabsPanels>
33</Tabs>

API reference

<Tabs />
PropTypeDefaultDescription
initialActiveTabstring | numberundefinedTab ID to activate on first render.
activeTabstring | numberundefinedActive tab ID when you hold the state yourself (controlled).
onActiveTabChange(tabId) => voidundefinedCalled with the new tab ID whenever the active tab changes.
classNamestringundefinedClass applied to the wrapper div.
childrenReactNode—Should contain TabsList and TabsPanels.
...propsdiv attributes—id, ref, style, data-*, aria-*… passed to the wrapper div.
<TabsTab />
PropTypeDefaultDescription
tabIdstring | number—Unique ID matching the TabsPanel to activate.
classNamestringundefinedClass applied when the tab is inactive.
activeClassNamestringundefinedClass applied when the tab is active.
onClickMouseEventHandlerundefinedRuns before the tab is activated. Call event.preventDefault() to keep the current tab.
childrenReactNode—Tab label content.
...propsbutton attributes—id, ref, style, data-*, aria-*… passed to the button.
<TabsPanel />
PropTypeDefaultDescription
tabIdstring | number—ID of the tab that reveals this panel.
classNamestringundefinedClass applied to the panel wrapper.
childrenReactNode—Panel content — stays mounted when inactive.
...propsdiv attributes—id, ref, style, data-*, aria-*… passed to the panel div.