"use client";
import { X } from "lucide-react";Installation
$ pnpm dlx shadcn@latest add https://motif.sweetretry.com/r/line-tabs.json
Usage
import {
LineTabs,
LineTabsContent,
LineTabsList,
LineTabsTrigger,
} from "@/components/ui/line-tabs";
export function Panel() {
return (
<LineTabs defaultValue="results">
<div className="flex items-center justify-between border-b pl-6 pr-3">
<LineTabsList aria-label="Sections">
<LineTabsTrigger value="results">Results</LineTabsTrigger>
<LineTabsTrigger value="files">Files</LineTabsTrigger>
</LineTabsList>
<CloseButton />
</div>
<LineTabsContent value="results">…</LineTabsContent>
<LineTabsContent value="files">…</LineTabsContent>
</LineTabs>
);
}The parts are Radix tabs under new names, so the selection works the way Radix's does:
controlled with value and onValueChange, or uncontrolled with defaultValue.
Words, not a control
The selected tab is the word at full strength with a 2px line under it. The others are muted words. That is the whole vocabulary: there is no plate behind the selection and no tray the tabs sit in.
The choice is about what the strip is part of. A segmented control is a thing you operate, so it earns a surface. Tabs in a panel header are part of the header: they name the sections of the panel, and a pill inside a header is a second surface inside the first, one more edge for the eye to read before it reaches the content. Words on the header's own rule add none.
The rule is the header's
LineTabsList draws no border. The rule under the strip belongs to the header the strip
sits in, and the selected line lands on it. A strip that drew its own rule would stack two
the moment it was dropped into a header that already had one, and would have no way to be
used in a place that has none.
So the pairing is the caller's: put a border-b on the row that holds the list, and put
the panel's own controls opposite the tabs in the same row. Every trigger is h-12 and the
list stretches to it; pass a className to change the height for a tighter header.
The line follows the word
The line is an inset shadow in currentColor, and the selected tab's text is the
foreground, so the line is always exactly the colour of the word above it, in any theme,
without a token of its own. A count or a glyph inside the trigger is part of the word and
shares the line.
Keyboard
Focus is one tab stop with a roving tab: arrow keys move between tabs, Home and End jump to
the ends, and selection follows focus. A tab names its panel through aria-controls, and
the panel names the tab back, which Radix wires from the value you give both. Only the
selected panel is mounted.
Why not Tabs
The inherited Tabs is a pill in a tray, and that look lives inside the component:
restyling it to a line means overriding the tray, the active fill, the border and the dark
variants of each, and any one of those drifts the next time the base changes. This is the
same primitive with a different skin, kept as its own component rather than a long
className that every caller would have to repeat.
| Part | Notes |
|---|---|
LineTabs | The root. value / onValueChange, or defaultValue |
LineTabsList | The strip. As wide as its tabs; draws no rule |
LineTabsTrigger | One tab. h-12, the line under it when selected |
LineTabsContent | A panel, mounted only while its tab is selected |
Component source
"use client";
import { Tabs as TabsPrimitive } from "radix-ui";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
/* -- A word with a line under it ---------------------------------------------
* The selected tab is the word at full strength with a 2px line laid under it;
* the rest are muted words. There is no plate and no pill, so the strip draws
* no surface of its own: it sits on whatever rule the header around it already
* has, and the line reads as that rule thickening under one word.
*
* The list carries no border for that reason. A caller that wants a rule draws
* it on the header, where it belongs; a strip that drew its own would stack two
* rules the moment it landed in a header that already had one.
*
* Behaviour is Radix's tabs, unchanged: one tab stop with a roving focus, arrow
* keys, Home and End, and the ids and `aria-controls` that tie a tab to its
* panel. This file only decides how it looks.
* --------------------------------------------------------------------------- */
/** The root owns the selection, controlled with `value` or uncontrolled with
* `defaultValue`, like any Radix tabs. */
const LineTabs = ({
className,
...props
}: ComponentProps<typeof TabsPrimitive.Root>) => (
<TabsPrimitive.Root
className={cn("flex flex-col", className)}
data-slot="line-tabs"
{...props}
/>
);
/** The strip. It is as wide as its tabs and no wider, so a toolbar can sit opposite it. */
const LineTabsList = ({
className,
...props
}: ComponentProps<typeof TabsPrimitive.List>) => (
<TabsPrimitive.List
className={cn("flex items-stretch gap-5", className)}
data-slot="line-tabs-list"
{...props}
/>
);
/** One tab. The line is an inset shadow in the text's own colour, so it follows the
* word through any theme and never needs a token of its own. */
const LineTabsTrigger = ({
className,
...props
}: ComponentProps<typeof TabsPrimitive.Trigger>) => (
<TabsPrimitive.Trigger
className={cn(
"inline-flex h-12 cursor-pointer items-center text-sm whitespace-nowrap text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-[inset_0_-2px_0_currentColor]",
className
)}
data-slot="line-tabs-trigger"
{...props}
/>
);
/** A panel. Only the selected one is mounted. */
const LineTabsContent = ({
className,
...props
}: ComponentProps<typeof TabsPrimitive.Content>) => (
<TabsPrimitive.Content
className={cn("outline-none", className)}
data-slot="line-tabs-content"
{...props}
/>
);
export { LineTabs, LineTabsContent, LineTabsList, LineTabsTrigger };