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 inside any child component to read or set the active tab from outside the tab buttons.

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>

API reference

<Tabs />
PropTypeDefaultDescription
initialActiveTabstring | numberundefinedTab ID to activate on first render.
classNamestringundefinedClass applied to the wrapper div.
childrenReactNode—Should contain TabsList and TabsPanels.
<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.
childrenReactNode—Tab label content.
<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.