TabList

Data-driven responsive tab list with overflow handling.

Recipe Source

components/ui/tab-list.tsx
"use client";import type { Tabs as TabsPrimitive } from "@base-ui/react/tabs";import {	DropdownMenu,	DropdownMenuContent,	DropdownMenuItem,	DropdownMenuTrigger,} from "@giddaa-housing/ui/dropdown-menu";import {	Tabs,	TabsCount,	TabsList,	TabsTrigger,	tabsTriggerClassName,} from "@giddaa-housing/ui/tabs";import { ChevronDownIcon } from "lucide-react";import { useState } from "react";import { cn } from "./lib/cn";import { useIsMobile } from "./lib/use-mobile";type TabsListVariant = "default" | "line" | "secondary" | "underline";type TabSize = "sm" | "md" | "lg";type TabListItem = {	/** Stable value used to match the active tab. */	value: string;	/** Visible label. */	label: React.ReactNode;	/** Optional count badge rendered after the label. */	count?: number;	disabled?: boolean;	/**	 * Optional element to render the trigger as (e.g. a router `<Link>`).	 * Used for both the in-bar tab and its overflow menu item.	 */	render?: React.ReactElement;};type TabListProps = Omit<	TabsPrimitive.Root.Props,	"children" | "value" | "defaultValue" | "onValueChange"> & {	items: TabListItem[];	value?: string;	defaultValue?: string;	onValueChange?: (value: string) => void;	variant?: TabsListVariant;	/** Tab sizing, forwarded to the underlying `Tabs`. */	size?: TabSize;	/** Max tabs shown inline on small screens before collapsing into "More". */	maxVisibleMobile?: number;	/** Max tabs shown inline on larger screens before collapsing into "More". */	maxVisibleDesktop?: number;	/** Label for the overflow trigger. */	moreLabel?: React.ReactNode;	className?: string;	listClassName?: string;	/** Tab panels (`TabsContent`) to render under the bar. */	children?: React.ReactNode;};function TabList({	items,	value,	defaultValue,	onValueChange,	variant = "line",	size,	maxVisibleMobile = 2,	maxVisibleDesktop = 4,	moreLabel = "More",	className,	listClassName,	children,	...rootProps}: TabListProps) {	const isMobile = useIsMobile();	const [internalValue, setInternalValue] = useState(		value ?? defaultValue ?? items[0]?.value,	);	const currentValue = value ?? internalValue;	const selectValue = (next: string) => {		if (value === undefined) setInternalValue(next);		onValueChange?.(next);	};	const maxVisible = isMobile ? maxVisibleMobile : maxVisibleDesktop;	const visibleItems = items.slice(0, maxVisible);	const overflowItems = items.slice(maxVisible);	const isOverflowActive = overflowItems.some(		(item) => item.value === currentValue,	);	return (		<Tabs			value={currentValue}			onValueChange={(next) => selectValue(next as string)}			size={size}			className={className}			{...rootProps}		>			<TabsList variant={variant} className={listClassName}>				{visibleItems.map((item) => (					<TabsTrigger						key={item.value}						value={item.value}						disabled={item.disabled}						nativeButton={item.render ? false : undefined}						render={item.render}					>						{item.label}						{item.count != null && <TabsCount>{item.count}</TabsCount>}					</TabsTrigger>				))}				{overflowItems.length > 0 && (					<DropdownMenu>						<DropdownMenuTrigger							render={								// Not a real tab — reuse the trigger look so it tracks the								// active variant + size. There's no moving indicator under								// it, so when an overflowed tab is selected we apply the								// active affordance directly: an underline bar (line) or a								// brand pill (secondary).								<button									type="button"									data-active={isOverflowActive || undefined}									className={cn(										tabsTriggerClassName,										"group/tab-list-more",										isOverflowActive && [											"group-data-[variant=underline]/tabs-list:text-fg-primary group-data-[variant=underline]/tabs-list:shadow-[inset_0_-2px_0_0_var(--color-line-focus)]",											"group-data-[variant=secondary]/tabs-list:bg-surface-brand group-data-[variant=secondary]/tabs-list:text-fg-on-brand group-data-[variant=secondary]/tabs-list:shadow-(--elevation-e1-shadow)",										],									)}								/>							}						>							{moreLabel}							<ChevronDownIcon className="transition-transform duration-200 group-data-[popup-open]/tab-list-more:rotate-180" />						</DropdownMenuTrigger>						<DropdownMenuContent align="end">							{overflowItems.map((item) => (								<DropdownMenuItem									key={item.value}									disabled={item.disabled}									data-active={item.value === currentValue || undefined}									className="justify-between gap-3 data-active:text-fg-brand"									render={item.render}									onClick={() => selectValue(item.value)}								>									{item.label}									{item.count != null && (										<span className="ml-auto inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-surface-raised px-1 text-[11px] leading-none font-semibold text-fg-secondary">											{item.count}										</span>									)}								</DropdownMenuItem>							))}						</DropdownMenuContent>					</DropdownMenu>				)}			</TabsList>			{children}		</Tabs>	);}export { TabList, type TabListItem };

Usage

import { TabList } from "@/components/recipes/tab-list";
import { TabsContent } from "@giddaa-housing/ui/tabs";

<TabList items={items}><TabsContent value="overview">Overview</TabsContent></TabList>

Examples

Default TabList

A data-driven bar built on the Tabs primitives. Pass items with label and optional count; tabs past the max collapse into a More dropdown that mirrors the trigger styling and turns active when a hidden tab is selected. Copy this recipe into your app and keep product data, routing, fetching, and authorization logic in the consuming code.

Summary content.

Props

Prop/APITypeDefaultDescription
itemsTabListItem[]-Tab value, label, optional count and render.
value / defaultValuestringfirst itemControlled / initial selected value.
variant`"default""line""secondary"
size`"sm""md""lg"`
maxVisibleDesktopnumber4Inline tabs before overflow on larger screens.
maxVisibleMobilenumber2Inline tabs before overflow on small screens.
moreLabelReactNode"More"Label for the overflow trigger.
classNamestring-Local layout or spacing overrides.
native/root propsReact component props-Passed through to the underlying root or primitive.

Accessibility

  • Provide visible labels or accessible names for interactive controls.
  • Preserve the component-provided focus, keyboard, disabled, and invalid-state behavior.
  • Do not communicate state with color alone; include text, icons, or helper copy.

On this page