Tabs

A tab strip in two flavours - a filled pill on a muted track, and a bare line with a 2px rule under whichever tab is selected.

Tabs / Trigger · Variant × Active × State × Show IconLive in the browser · drawn to match the file
Variant
Active
State
Show Icon
Tabs Text
74 × 24 in the fileradius.smbase.foreground
28 of a possible 32

Four of the cells are deliberately absent: Active On has no Hover, in either variant, with or without an icon. A tab that is already selected has nothing to offer when you point at it, and drawing a cell for it would invite someone to make one up. The sparse matrix is the honest shape here.

Two variants, two ways to say active

Default lifts the selected tab onto a white card - base.background, a 1px base.input edge and a small drop shadow - inside a base.muted track. Line drops the track entirely and marks the selection with a 2px rule in base.foreground. Off looks identical in both.

Show Icon widens the trigger

A 16px glyph and its 6 gap take the trigger from 74 to 96. The icon is bound to the same token as the label - base.foreground when the tab is on or hovered, base.muted-foreground when it is not - so the two never drift apart. Hover still moves nothing but the colour; there is no hover surface on an unselected tab in either variant.

The second component

Three of the four lists

Tabs is the strip the triggers sit in, published as Variant × Orientation. Three of the four combinations exist: filled horizontal, line horizontal and filled vertical. Line vertical is not in the file, which in the source would put the 2px rule down the inline end of the tab rather than under it.

Combination
228 × 30 in the filethree triggers, no gap, base.muted behindbase.muted · radius.lg
The track is 3 of padding

A 24 trigger inside 3 either side gives a 30 strip, which is what every filled strip in the file measures and what the source's p-[3px] asks for. One number covers the component, the usage frame and a coded rebuild, which is the point of writing it down.

Line has no surface at all

No fill, no radius, no border under the strip. The only thing marking the selection is the rule under the active trigger, so a line strip needs the content below it to provide the edge - a panel, a table header, a rule of its own.

There is no Content component

The source has four parts; the kit draws two. TabsContent is whatever you put under the strip, so the panel in the usage frame is a plain board rather than an instance. That is the right call - a panel with a fixed size would be wrong in every layout but one.

The usage frame

Four strips, one trigger

620 by 692, holding a filled strip over a panel, a line strip, a vertical strip and a two-tab strip with one tab disabled. Every trigger in all four is an instance; the strips themselves are local boards, because each one is a different width and a component copy cannot be re-populated.

Example
OverviewYour key metrics and recent project activity, tracked across every workspace you belong to.You have 12 active projects and 3 pending reviews.
strip 302 × 30 · panel 460 × 118
Five more places it earns its keep

Tabs are a promise that nothing is lost

A tab strip says: these panels are peers, they belong to one thing, and switching between them costs nothing. Every example here holds to that, and the last two are about the cases where it stops being true.

Example
The rendered component, as the reader would meet it.

Show Icon is a real axis on the trigger rather than a layer you toggle, so an instance either has the glyph or it does not and there is nothing to forget to turn on. The icon sits before the label with 6 between them and takes the trigger from 74 to 96. It carries the label's own token, so it goes muted when the tab is off and dark when the tab is chosen or hovered - point at one and watch both move together.

The one thing to avoid is an icon with no label. A tab strip is a set of destinations, and a row of glyphs asks the reader to guess at every one of them before clicking. If space is that tight, the strip has too many tabs rather than too much text.

Right to left

The first tab belongs at the reading edge

Under dir=rtl the strip reverses on its own and the first tab lands on the right. The underline needs no work at all - it is a child of its own trigger, anchored to both inline edges, so it follows whatever the trigger does. The two things that do need a decision are the arrow keys and, in a vertical line strip, which side the rule sits on.

dir=ltr
dir=rtl
Ordered, so dir is enough

The strip is a flex row of peers in reading order. Setting dir on an ancestor reverses it and nothing else has to change. Adding flex-row-reverse on top would cancel the dir out and put you back where you started, facing the wrong way.

The rule is anchored, not placed

Drawn with inset-inline-start and inset-inline-end at 0, the underline spans its trigger in either direction with no arithmetic. In the file it is a rectangle the width of the trigger, which comes to the same thing - it moves because the trigger moves.

Arrows swap, tab order does not

Left and right arrows follow the visual direction, so under rtl the left arrow moves to the next tab. Home and End follow reading order and need no change. Try it: focus a tab in either column and hold an arrow key.

In the file

Two components, 31 variants

Read straight out of the Penpot file. The axes below are the property names you will see in the variant dropdown.

Tabs / TriggerVariant × Active × State × Show IconDefault · Line × On · Off × Default · Hover · Focus · Disabled × No · Yes - 74 wide, or 96 with the icon; 24 tall on Default and 36 on Line; Active On has no Hover28
TabsVariant × OrientationDefault / Default 228 × 30 · Line / Default 236 × 36 · Default / Vertical 80 × 78 - Line / Vertical is not published3

Using the Tabs

Where the selection lives, and how tall the trigger has to be. Default lifts the active tab onto a white card - base.background, a 1px base.input edge and a small drop shadow - inside a base.muted track with radius.lg, and its trigger is 24. Line throws the track away: no fill, no radius, and the selection is a 2px base.foreground rule along the bottom of the trigger, which grows to 36 so the rule clears the label. Unselected tabs look identical in both, which is why they read as the same component.

Because Active On has no Hover, in either variant, with or without an icon. A tab that is already selected has nothing to promise when you point at it, so the file leaves those four cells out rather than drawing something arbitrary. Everything else is a full 2 × 2 × 4 × 2 grid: two variants, on and off, Default, Hover, Focus and Disabled, and the icon on or off.

The label and only the label, from base.muted-foreground to base.foreground. There is no hover surface behind an unselected tab in either variant, which keeps the selected tab the only thing carrying any weight. Disabled is different again - the whole variant drops to 50% rather than recolouring anything.

Along the bottom edge of the trigger, as a 2px rectangle the full width of it, bound to base.foreground. That is why the Line trigger is 36 tall where the filled one is 24: the rule needs air under the label, and Line has no track to supply it. The gap comes to 6 under the label's box and 8 under the icon, which is close to the source's own 5 below the trigger - and 36 is exactly the h-9 the source gives its line list.

Set Show Icon to Yes on the trigger. It is a real axis rather than a layer you toggle, so an instance either carries the glyph or it does not. The icon is 16 × 16, sits before the label with 6 between them, and takes the trigger from 74 to 96. It is bound to the same token as the label - base.foreground when the tab is on or hovered, base.muted-foreground when it is not - so the two can never drift apart. The component ships Tabler's app-window as its placeholder; swap the glyph on the instance.

Either all of them or none. A strip where two tabs have icons and two do not reads as two groups, and the eye finds the odd one out before it reads any label. What to avoid entirely is an icon with no label: a tab strip is a set of destinations, and a row of bare glyphs asks the reader to guess at each one. If the labels genuinely do not fit, the strip has too many tabs rather than too much text.

Not from the file - the Tabs component publishes three of its four combinations and Line / Vertical is the one missing. In the source, a vertical line strip moves the 2px rule to the inline end of the trigger rather than under it. Draw it by hand or ask for the fourth combination; the trigger itself needs nothing new.

No, and there should not be. The source has four parts - Tabs, TabsList, TabsTrigger, TabsContent - and the kit draws the middle two. A panel component with a fixed size would be wrong in every layout but the one it was drawn for, so the panel in the usage frame is a plain board you are meant to replace.

Six is the ceiling and four is comfortable. Past that the labels either squeeze past readability, hide behind a horizontal scroll nobody discovers, or get folded into a select - and a select defeats the one thing tabs are for, which is seeing the options without clicking. Eight peers is a page that wants splitting, not a strip that wants scrolling.

Yes, with dir on an ancestor and nothing else. The strip is a row of peers in reading order, so it reverses on its own, and the underline is a child of its trigger anchored to both inline edges - it moves because the trigger moves. Never add flex-row-reverse on top; it cancels the dir out. The two things that do need thought are the arrow keys, which follow the visual direction, and a vertical line strip's rule, which belongs on the inline end.

Navigation · 31 variants