Skip to content

Tabs <l-tabs> ​

Tabs organize content into panels, showing one at a time. Progressive enhancement — plain HTML is enhanced with ARIA roles, keyboard navigation, and an animated indicator. Mark the initially-active tab with aria-selected="true" so it is styled as selected before the script loads.

HTML tag<l-tabs>
⏣Native
⬡Progressive
◇Plain
⬢Shadow-DOM
Progressive Custom Element

Options ​

Enclosed variant ​

Add variant="enclosed" for a pill-shaped tablist with a sliding background indicator.

Make changes to your account here.
Change your password here.
Manage your notification preferences.
Code
html
<l-tabs variant="enclosed">
  <div>
    <button
      name="account"
      aria-selected="true"
    >
      Account
    </button>
    <button name="password">Password</button>
    <button name="notifications">Notifications</button>
  </div>
  <div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
  <div class="p-4 text-sm text-secondary">Change your password here.</div>
  <div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
</l-tabs>

Line variant ​

Add variant="line" for a tablist with a sliding underline indicator.

Make changes to your account here.
Change your password here.
Manage your notification preferences.
Code
html
<l-tabs variant="line">
  <div>
    <button
      name="account"
      aria-selected="true"
    >
      Account
    </button>
    <button name="password">Password</button>
    <button name="notifications">Notifications</button>
  </div>
  <div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
  <div class="p-4 text-sm text-secondary">Change your password here.</div>
  <div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
</l-tabs>

Hovering a tab reveals a subtle pill behind its label, detached from the bottom border. Restyle it with --hover-color (and --hover-inset for the gap), along with the active underline and the static bottom border via --indicator-color, --indicator-thickness, --track-color and --track-thickness.

High-level overview.
Traffic and engagement analytics.
Exported reports.
Code
html
<l-tabs
  variant="line"
  style="
    --indicator-color: var(--l-color-bg-fill-info-strong);
    --indicator-thickness: 3px;
    --track-color: var(--l-color-divider);
    --hover-color: var(--l-color-bg-fill-info-soft);
  "
>
  <div>
    <button
      name="overview"
      aria-selected="true"
    >
      Overview
    </button>
    <button name="analytics">Analytics</button>
    <button name="reports">Reports</button>
  </div>
  <div class="p-4 text-sm text-secondary">High-level overview.</div>
  <div class="p-4 text-sm text-secondary">Traffic and engagement analytics.</div>
  <div class="p-4 text-sm text-secondary">Exported reports.</div>
</l-tabs>

Full width ​

Add full-width to stretch tabs across the container.

Make changes to your account here.
Change your password here.
Manage your notification preferences.
Code
html
<l-tabs
  variant="enclosed"
  full-width
>
  <div>
    <button
      name="account"
      aria-selected="true"
    >
      Account
    </button>
    <button name="password">Password</button>
    <button name="notifications">Notifications</button>
  </div>
  <div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
  <div class="p-4 text-sm text-secondary">Change your password here.</div>
  <div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
</l-tabs>

Default active tab ​

Set value="1" to activate a specific tab on load (0-based index).

Make changes to your account here.
Change your password here.
Manage your notification preferences.
Code
html
<l-tabs
  variant="enclosed"
  value="1"
>
  <div>
    <button name="account">Account</button>
    <button
      name="password"
      aria-selected="true"
    >
      Password
    </button>
    <button name="notifications">Notifications</button>
  </div>
  <div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
  <div class="p-4 text-sm text-secondary">Change your password here.</div>
  <div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
</l-tabs>

Disabled tab ​

Add disabled to a tab button to take it out of play. It is skipped by arrow keys, Home and End, it cannot be selected by a click or by value, and it never shows the hover pill. aria-disabled="true" behaves identically — reach for it only when the native attribute is out of reach (a framework binding, a non-button element).

l-tabs itself takes no disabled attribute. To freeze tab switching — while a page loads, say — put inert on the tablist div: the tabs leave the tab sequence and stop answering clicks, and the active panel stays reachable.

Make changes to your account here.
Change your password here.
Upgrade your plan to manage billing.
Manage your notification preferences.
Code
html
<l-tabs variant="line">
  <div>
    <button
      name="account"
      aria-selected="true"
    >
      Account
    </button>
    <button name="password">Password</button>
    <button
      name="billing"
      disabled
    >
      Billing
    </button>
    <button name="notifications">Notifications</button>
  </div>
  <div class="p-4 text-sm text-secondary">Make changes to your account here.</div>
  <div class="p-4 text-sm text-secondary">Change your password here.</div>
  <div class="p-4 text-sm text-secondary">Upgrade your plan to manage billing.</div>
  <div class="p-4 text-sm text-secondary">Manage your notification preferences.</div>
</l-tabs>

Panels with interactive content ​

Tab moves focus out of the tablist and into the active panel, so the content at the top of the panel is reachable. A panel that opens with something focusable needs no stop of its own: l-tabs leaves its tabindex off and Tab reaches that first control directly, instead of stopping on the panel box first.

Every other panel keeps tabindex="0" — including one that opens with text and holds a button further down. Dropping the stop there would send a keyboard user past everything above that button.

Download the report

This panel opens with a link, so pressing Tab from the tablist moves focus straight to it.

This panel opens with text, so it takes a tab stop of its own — Tab lands on the panel, then on the link.

Download the summary
Code
html
<l-tabs variant="line">
  <div>
    <button
      name="report"
      aria-selected="true"
    >
      Report
    </button>
    <button name="summary">Summary</button>
  </div>
  <div class="p-4 text-sm text-secondary">
    <a
      href="#"
      class="font-semibold text-primary underline-offset-4 hover:underline"
      >Download the report</a
    >
    <p class="mt-2">
      This panel opens with a link, so pressing Tab from the tablist moves focus straight to it.
    </p>
  </div>
  <div class="p-4 text-sm text-secondary">
    <p>
      This panel opens with text, so it takes a tab stop of its own — Tab lands on the panel, then
      on the link.
    </p>
    <a
      href="#"
      class="mt-2 inline-block font-semibold text-primary underline-offset-4 hover:underline"
      >Download the summary</a
    >
  </div>
</l-tabs>

The check runs again whenever a panel's content changes, so a panel filled after mount — a framework rendering into it once its data lands — is treated the same as one present in the initial markup. A tabindex you write on a panel yourself is left alone.

Accessibility ​

Criteria ​

Role

First child div gets role="tablist", buttons get role="tab", remaining divs get role="tabpanel"

WCAG4.1.2
RGAA7.1
Linked controls

Each tab has aria-controls pointing to its panel; each panel has aria-labelledby pointing back to its tab

WCAG1.3.1
Selection state

Active tab has aria-selected="true"; inactive tabs have aria-selected="false"

WCAG4.1.2
Focus management

Roving tabindex — active tab has tabindex="0", others tabindex="-1". A panel takes tabindex="0" unless its first content is already focusable, per the APG

WCAG2.1.1
RGAA12.13
Disabled tabs

Tabs marked disabled or aria-disabled="true" are skipped by arrow keys, Home and End, and never take the roving tabindex="0" — so the tablist always keeps a reachable entry point

WCAG2.1.1
RGAA7.3
Hidden content

Inactive panels use the hidden attribute

WCAG1.3.2
Motion

Indicator animation respects prefers-reduced-motion

WCAG2.3.3

Rules

  • The first child of l-tabs must be a div containing button elements for the tab triggers
  • Each remaining child div maps to a tab panel in order
  • Do not add role or aria-* attributes manually — the custom element sets them automatically
  • l-tabs has no host-level disabled attribute — put inert on the tablist div to freeze tab switching

Keyboard interactions ​

ArrowRight
Moves focus to the next enabled tab and activates it (horizontal orientation)
ArrowLeft
Moves focus to the previous enabled tab and activates it (horizontal orientation)
ArrowDown
Moves focus to the next enabled tab and activates it (vertical orientation)
ArrowUp
Moves focus to the previous enabled tab and activates it (vertical orientation)
Home
Moves focus to the first enabled tab and activates it
End
Moves focus to the last enabled tab and activates it
Tab
Moves focus out of the tablist — to the active panel, or straight to its first control when the panel opens with one

API reference ​

Importing ​

js
import 'luxen-ui/tabs';
css
@import 'luxen-ui/css/tabs/enclosed';
css
@import 'luxen-ui/css/tabs/line';

Attributes & Properties ​

variantTabsVariantdefault:'line'Property
Visual variant.
valuestringdefault:'0'Property
Index of the active tab (0-based).
full-widthbooleandefault:falseProperty
Stretch tabs to fill container width.
orientationTabsOrientationdefault:'horizontal'Property
Tab orientation.

Events ​

changeEvent
Fired when the active tab changes. Bubbles. Not cancelable. Properties: index: number, name: string | null.

CSS custom properties ​

--indicator-colordefault:var(--l-color-text-primary)Custom property
line variant: color of the active underline that slides under the selected tab.
--indicator-thicknessdefault:2pxCustom property
line variant: thickness of the active underline.
--track-colordefault:var(--l-color-border)Custom property
line variant: color of the static bottom border the tabs sit on.
--track-thicknessdefault:1pxCustom property
line variant: thickness of the static bottom border.
--hover-colordefault:var(--l-color-bg-state-hover)Custom property
line variant: fill of the pill that appears behind a hovered tab label.
--hover-insetdefault:4pxCustom property
line variant: block-axis gap between the hover pill and the tab edges, keeping it detached from the bottom border.