TabList
Data-driven responsive tab list with overflow handling.
Recipe Source
"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/API | Type | Default | Description |
|---|---|---|---|
items | TabListItem[] | - | Tab value, label, optional count and render. |
value / defaultValue | string | first item | Controlled / initial selected value. |
variant | `"default" | "line" | "secondary" |
size | `"sm" | "md" | "lg"` |
maxVisibleDesktop | number | 4 | Inline tabs before overflow on larger screens. |
maxVisibleMobile | number | 2 | Inline tabs before overflow on small screens. |
moreLabel | ReactNode | "More" | Label for the overflow trigger. |
className | string | - | Local layout or spacing overrides. |
| native/root props | React 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.