Underline tabs
Tabs on a hairline with one 2px underline that slides to the active tab.
npx shadcn@latest add https://ui.goodtabs.co/r/underline-tabs.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/underline-tabs
Three services, all healthy. Last deploy 14 minutes ago.
Three tabs with content. Arrow keys move between them.
With counts
12 open issues, 4 assigned to you.
A count pill after each label.
In a page header
Project
Northwind
Production is on main at 4f2c1a9, deployed 14 minutes ago.
A title above the row, content panels below, inside one frame.
Usage
import {
UnderlineTabs,
UnderlineTabsContent,
UnderlineTabsList,
UnderlineTabsTrigger,
} from "@/components/ui/underline-tabs"
<UnderlineTabs defaultValue="overview">
<UnderlineTabsList aria-label="Project">
<UnderlineTabsTrigger value="overview">Overview</UnderlineTabsTrigger>
<UnderlineTabsTrigger value="activity" count={3}>Activity</UnderlineTabsTrigger>
</UnderlineTabsList>
<UnderlineTabsContent value="overview">...</UnderlineTabsContent>
<UnderlineTabsContent value="activity">...</UnderlineTabsContent>
</UnderlineTabs>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | string | Optional count pill after the label on UnderlineTabsTrigger. | |
| value, defaultValue, onValueChange | radix Tabs props | Controlled or uncontrolled, on UnderlineTabs. |
Source
underline-tabs/underline-tabs.tsx
"use client";
import * as React from "react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
/** Root. Controlled or uncontrolled through radix: value, defaultValue, onValueChange. */
function UnderlineTabs({ className, ...props }: React.ComponentProps<typeof Tabs>) {
return <Tabs data-slot="underline-tabs" className={cn("gap-4", className)} {...props} />;
}
/**
* The tab row: a hairline underneath and one 2px indicator that slides to the active trigger.
* The indicator is a sibling, so triggers stay plain buttons for radix and screen readers.
*/
function UnderlineTabsList({ className, children, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
const ref = React.useRef<HTMLDivElement>(null);
const [indicator, setIndicator] = React.useState<{ x: number; width: number } | null>(null);
React.useEffect(() => {
const list = ref.current;
if (!list) return;
const measure = () => {
const active = list.querySelector<HTMLElement>('[role="tab"][data-state="active"]');
if (!active) return setIndicator(null);
setIndicator({ x: active.offsetLeft, width: active.offsetWidth });
};
measure();
const resize = new ResizeObserver(measure);
resize.observe(list);
for (const tab of list.querySelectorAll<HTMLElement>('[role="tab"]')) resize.observe(tab);
const mutation = new MutationObserver(measure);
mutation.observe(list, { attributes: true, subtree: true, attributeFilter: ["data-state"] });
return () => {
resize.disconnect();
mutation.disconnect();
};
}, []);
return (
<TabsPrimitive.List
ref={ref}
data-slot="underline-tabs-list"
data-measured={indicator ? "true" : "false"}
className={cn(
"group/underline-tabs-list relative flex w-full items-end overflow-x-auto border-b text-muted-foreground",
className,
)}
{...props}
>
{children}
{indicator && (
<span
aria-hidden="true"
data-slot="underline-tabs-indicator"
className="absolute bottom-0 left-0 h-0.5 bg-foreground transition-[transform,width] duration-150 ease-out motion-reduce:transition-none"
style={{ width: indicator.width, transform: `translateX(${indicator.x}px)` }}
/>
)}
</TabsPrimitive.List>
);
}
type TriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {
/** Optional count in a pill after the label. */
count?: number | string;
};
function UnderlineTabsTrigger({ className, count, children, ...props }: TriggerProps) {
return (
<TabsPrimitive.Trigger
data-slot="underline-tabs-trigger"
className={cn(
"relative inline-flex h-9 shrink-0 items-center gap-1.5 px-1 text-sm font-medium whitespace-nowrap transition-colors duration-100 ease-out outline-none not-last:mr-4 hover:text-foreground focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground",
// Before the first measurement, the active trigger draws its own underline so the row is never bare.
"after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground after:opacity-0 group-data-[measured=false]/underline-tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
>
{children}
{count !== undefined && (
<span
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 font-mono text-xs font-normal text-foreground/70 tabular-nums"
aria-label={`${count} items`}
>
{count}
</span>
)}
</TabsPrimitive.Trigger>
);
}
function UnderlineTabsContent({ className, ...props }: React.ComponentProps<typeof TabsContent>) {
return (
<TabsContent
data-slot="underline-tabs-content"
className={cn("focus-visible:ring-2 focus-visible:ring-ring/50", className)}
{...props}
/>
);
}
export { UnderlineTabs, UnderlineTabsList, UnderlineTabsTrigger, UnderlineTabsContent };
For AI agents
Hand your agent this page, the markdown at /components/underline-tabs/prompt.md, or the Copy AI prompt button above.
When to use
- Sections of one page under its title: overview, activity, settings
- When the row should span the full width and sit flush on a hairline
- For three to seven tabs with short labels and optional counts
When not to use
- For a compact switch inside a card or toolbar, use segmented-tabs
- For route-level navigation, use links and the navbar or sidebar-nav
- For long lists of options, use a select or vertical-tabs
Agent notes
- Radix Tabs underneath, through the shadcn tabs root and content: value, defaultValue and onValueChange work as usual
- The indicator is measured with ResizeObserver and slides over 150ms; under reduced motion it jumps
- Before the first measurement the active trigger draws its own underline, so the server render is not bare
- The list is w-full with a border-b hairline and scrolls horizontally when the tabs overflow