Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
countnumber | stringOptional count pill after the label on UnderlineTabsTrigger.
value, defaultValue, onValueChangeradix Tabs propsControlled 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