Tabs
Tabbed content panels with keyboard-accessible tab triggers.
registry/default/tabs/tabs.tsx
"use client";
import {
createContext,
use,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { moveRovingFocus } from "@vllnt/ui";
import { cn } from "@vllnt/ui";
type TabsContextValue = {
activeTab: string;
baseId: string;
firstTab?: string;
panelIds: ReadonlyMap<string, string>;
registerPanel: (value: string, id: string) => () => void;
registerTab: (value: string, id: string) => () => void;
setActiveTab: (value: string) => void;
tabIds: ReadonlyMap<string, string>;
};
/**
* Tracks the element id rendered for each tab value, in mount order, so
* `aria-controls` / `aria-labelledby` reference rendered nodes and nothing else.
*/
function useIdRegistry(): readonly [
ReadonlyMap<string, string>,
(value: string, id: string) => () => void,
] {
const [ids, setIds] = useState<ReadonlyMap<string, string>>(new Map());
const register = useCallback((value: string, id: string) => {
setIds((current) =>
current.get(value) === id ? current : new Map(current).set(value, id),
);
return () => {
setIds((current) => {
if (current.get(value) !== id) return current;
const next = new Map(current);
next.delete(value);
return next;
});
};
}, []);
return [ids, register];
}
function toIdPart(value: string): string {
return encodeURIComponent(value);
}
const TabsContext = createContext<null | TabsContextValue>(null);
function useTabsContext(): TabsContextValue {
const context = use(TabsContext);
if (!context) {
throw new Error("Tab components must be used within a Tabs component");
}
return context;
}
export type TabsProps = {
children: ReactNode;
className?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
value?: string;
};
function Tabs({
children,
className,
defaultValue = "",
onValueChange,
value,
}: TabsProps): React.ReactNode {
const [internalTab, setInternalTab] = useState(defaultValue);
const isControlled = value !== undefined;
const activeTab = isControlled ? value : internalTab;
// Read the latest `onValueChange` without re-creating the context value.
const onValueChangeRef = useRef(onValueChange);
useEffect(() => {
onValueChangeRef.current = onValueChange;
}, [onValueChange]);
const handleSetActiveTab = useCallback(
(next: string): void => {
if (!isControlled) {
setInternalTab(next);
}
onValueChangeRef.current?.(next);
},
[isControlled],
);
const baseId = useId();
const [tabIds, registerTab] = useIdRegistry();
const [panelIds, registerPanel] = useIdRegistry();
const [firstTab] = tabIds.keys();
const contextValue = useMemo(
() => ({
activeTab,
baseId,
firstTab,
panelIds,
registerPanel,
registerTab,
setActiveTab: handleSetActiveTab,
tabIds,
}),
[
activeTab,
baseId,
firstTab,
handleSetActiveTab,
panelIds,
registerPanel,
registerTab,
tabIds,
],
);
return (
<TabsContext.Provider value={contextValue}>
<div className={cn("my-6", className)}>{children}</div>
</TabsContext.Provider>
);
}
export type TabsListProps = {
"aria-label"?: string;
children: ReactNode;
className?: string;
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
};
/**
* Container for the tab triggers. Implements the WAI-ARIA APG tabs keyboard
* model: ArrowLeft / ArrowRight move focus between tabs (wrapping), Home / End
* jump to the first / last tab, and the focused tab becomes active. A consumer
* `onKeyDown` runs first; calling `preventDefault()` in it opts out.
*/
function TabsList({
"aria-label": ariaLabel,
children,
className,
onKeyDown,
}: TabsListProps): React.ReactNode {
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
onKeyDown?.(event);
moveRovingFocus(event, '[role="tab"]', { activate: true });
};
return (
<div
aria-label={ariaLabel}
className={cn("flex border-b border-border overflow-x-auto", className)}
onKeyDown={handleKeyDown}
role="tablist"
tabIndex={-1}
>
{children}
</div>
);
}
export type TabsTriggerProps = {
"aria-controls"?: string;
"aria-hidden"?: "false" | "true" | boolean;
children: ReactNode;
className?: string;
id?: string;
tabIndex?: number;
value: string;
};
function TabsTrigger({
"aria-controls": ariaControls,
"aria-hidden": ariaHidden,
children,
className,
id,
tabIndex,
value,
}: TabsTriggerProps): React.ReactNode {
const {
activeTab,
baseId,
firstTab,
panelIds,
registerTab,
setActiveTab,
tabIds,
} = useTabsContext();
const isActive = activeTab === value;
const tabId = id ?? `${baseId}-tab-${toIdPart(value)}`;
const hasActiveTab = tabIds.has(activeTab);
const isTabStop = isActive || (!hasActiveTab && firstTab === value);
useEffect(() => registerTab(value, tabId), [registerTab, tabId, value]);
return (
<button
aria-controls={
ariaControls ?? (isActive ? panelIds.get(value) : undefined)
}
aria-hidden={ariaHidden}
aria-selected={isActive}
className={cn(
"px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors",
"border-b-2 -mb-px",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/50",
className,
)}
id={tabId}
onClick={() => {
setActiveTab(value);
}}
role="tab"
tabIndex={tabIndex ?? (isTabStop ? 0 : -1)}
type="button"
>
{children}
</button>
);
}
export type TabsContentProps = {
"aria-hidden"?: "false" | "true" | boolean;
"aria-labelledby"?: string;
children: ReactNode;
className?: string;
id?: string;
/**
* Tab order of the panel. By default the panel is a tab stop (`0`) when it
* holds no focusable content, so keyboard users can reach it (APG tabs).
*/
tabIndex?: number;
value: string;
};
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
/** Whether the panel holds a focusable element, re-checked as it changes. */
function useHasFocusableContent(
panelRef: React.RefObject<HTMLDivElement | null>,
isActive: boolean,
): boolean {
const [hasFocusable, setHasFocusable] = useState(false);
useLayoutEffect(() => {
const panel = panelRef.current;
if (!isActive || !panel) return;
const measure = (): void => {
setHasFocusable(panel.querySelector(FOCUSABLE) !== null);
};
measure();
if (typeof MutationObserver === "undefined") return;
const observer = new MutationObserver(measure);
observer.observe(panel, {
attributes: true,
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [isActive, panelRef]);
return hasFocusable;
}
function TabsContent({
"aria-hidden": ariaHidden,
"aria-labelledby": ariaLabelledBy,
children,
className,
id,
tabIndex,
value,
}: TabsContentProps): React.ReactNode {
const { activeTab, baseId, registerPanel, tabIds } = useTabsContext();
const isActive = activeTab === value;
const panelId = id ?? `${baseId}-panel-${toIdPart(value)}`;
const panelRef = useRef<HTMLDivElement>(null);
const hasFocusable = useHasFocusableContent(panelRef, isActive);
useEffect(() => {
if (!isActive) return;
return registerPanel(value, panelId);
}, [isActive, panelId, registerPanel, value]);
if (!isActive) return null;
return (
<div
aria-hidden={ariaHidden}
aria-labelledby={ariaLabelledBy ?? tabIds.get(value)}
className={cn("pt-4", className)}
id={panelId}
ref={panelRef}
role="tabpanel"
tabIndex={tabIndex ?? (hasFocusable ? undefined : 0)}
>
{children}
</div>
);
}
// Attach sub-components
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;
export { Tabs, TabsContent, TabsList, TabsTrigger };
- Where it goes
- kanban
DependenciesBundle weight · light
@vllnt/ui@^0.3.0