For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0

Line Tabs

Tabs drawn as words with a line under the selected one. No pill and no plate: the strip sits on the rule of the header around it, and the line reads as that rule thickening under one word.

What the thing is for, in a sentence or two.
"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.

PartNotes
LineTabsThe root. value / onValueChange, or defaultValue
LineTabsListThe strip. As wide as its tabs; draws no rule
LineTabsTriggerOne tab. h-12, the line under it when selected
LineTabsContentA panel, mounted only while its tab is selected

Component source

line-tabs.tsx
"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 };