Frontile

TabNav

A navigation bar styled like Tabs, for links that change the page.

Unlike Tabs, every link stays individually reachable by Tab and the arrow keys are left to the browser — the ARIA tabs pattern covers in-page panel switching, not navigation. The active link is marked with aria-current="page", which Ember's LinkTo does not set on its own.

Import

import { TabNav } from 'frontile';

Usage

The docs site has no routes for TabNav to link to, so every demo on this page uses @href and @isActive directly rather than @route. In an app with routes, @route (below) is the tier to reach for — it derives @isActive from the router for you.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { TabNav } from 'frontile';

export default class Example extends Component {
  @tracked current = 'account';

  isCurrent = (name: string): boolean => this.current === name;

  // No router in this demo, so a plain click stands in for a route
  // transition -- `preventDefault` keeps the `#` href from touching the URL.
  select = (name: string, event: MouseEvent): void => {
    event.preventDefault();
    this.current = name;
  };

  <template>
    <div class='demo-stack items-center'>
      <TabNav @label='Settings' as |nav|>
        <nav.Item
          @href='#account'
          @isActive={{this.isCurrent 'account'}}
          {{on 'click' (fn this.select 'account')}}
        >
          Account
        </nav.Item>
        <nav.Item
          @href='#security'
          @isActive={{this.isCurrent 'security'}}
          {{on 'click' (fn this.select 'security')}}
        >
          Security
        </nav.Item>
        <nav.Item
          @href='#billing'
          @isActive={{this.isCurrent 'billing'}}
          {{on 'click' (fn this.select 'billing')}}
        >
          Billing
        </nav.Item>
      </TabNav>
    </div>
  </template>
}

Linking to routes

Pass @route (with @models, @model, or @query as needed) and nav.Item renders an Ember LinkTo and derives @isActive from the router itself — no @isActive needed. This tier isn't rendered on this page, since the docs site has no matching routes; it is shown here as reference.

import { TabNav } from 'frontile';

<template>
  <TabNav @label='Settings' as |nav|>
    <nav.Item @route='settings.account'>Account</nav.Item>
    <nav.Item @route='settings.security'>Security</nav.Item>
    <nav.Item @route='settings.billing'>Billing</nav.Item>
  </TabNav>
</template>

@isActive always wins over anything derived from the router, so it can still override a @route item when needed.

The router's isActive check is a prefix match: a route also counts as active while any of its descendant routes are active. So @route='settings' and @route='settings.account' in the same list are both marked active while on settings.account — not just the more specific one. Pass an explicit @isActive on the parent item (e.g. comparing the current route name exactly) if you need only the leaf to light up.

nav also yields itemClass and setupItem directly, for a link component other than nav.Itemember-link, a custom <AppLink>. Apply itemClass to the link's class and {{nav.setupItem}} (with a boolean for whether the link is active) to its element, and it gets the same theme classes, data-selected, aria-current, and indicator animation as nav.Item. This tier isn't rendered here either, for the same reason as @route above.

import { TabNav } from 'frontile';

<template>
  <TabNav @label='Settings' as |nav|>
    <a href='/settings/account' class={{nav.itemClass}} {{nav.setupItem true}}>
      Account
    </a>
  </TabNav>
</template>

Variants, colors, and sizes

TabNav shares its theme with Tabs, so @variant, @color, and @size behave the same way — see Tabs for each option.

import { TabNav } from 'frontile';

<template>
  <div class='demo-stack items-center'>
    <TabNav @label='Solid' @variant='solid' @color='primary' as |nav|>
      <nav.Item @href='#account' @isActive={{true}}>Account</nav.Item>
      <nav.Item @href='#security'>Security</nav.Item>
    </TabNav>

    <TabNav @label='Underline' @variant='underline' @color='primary' as |nav|>
      <nav.Item @href='#account' @isActive={{true}}>Account</nav.Item>
      <nav.Item @href='#security'>Security</nav.Item>
    </TabNav>
  </div>
</template>

Vertical

import { TabNav } from 'frontile';

<template>
  <div class='demo-stack items-center'>
    <TabNav @label='Settings' @orientation='vertical' as |nav|>
      <nav.Item @href='#account' @isActive={{true}}>Account</nav.Item>
      <nav.Item @href='#security'>Security</nav.Item>
      <nav.Item @href='#billing'>Billing</nav.Item>
    </TabNav>
  </div>
</template>

Full width

@isFullWidth={{true}} stretches the bar to its container and gives every link equal width.

import { TabNav } from 'frontile';

<template>
  <div class='demo-stack items-center'>
    <div class='w-96 max-w-full rounded-lg border border-neutral-soft p-4'>
      <TabNav @label='Settings' @isFullWidth={{true}} as |nav|>
        <nav.Item @href='#account' @isActive={{true}}>Account</nav.Item>
        <nav.Item @href='#security'>Security</nav.Item>
      </TabNav>
    </div>
  </div>
</template>

@isDisabled drops the href as well as marking the link aria-disabled — an anchor cannot be natively disabled, so removing the href is what actually stops navigation.

import { TabNav } from 'frontile';

<template>
  <div class='demo-stack items-center'>
    <TabNav @label='Settings' as |nav|>
      <nav.Item @href='#account' @isActive={{true}}>Account</nav.Item>
      <nav.Item @href='#billing' @isDisabled={{true}}>Billing</nav.Item>
      <nav.Item @href='#security'>Security</nav.Item>
    </TabNav>
  </div>
</template>

Accessibility

TabNav renders a <nav> landmark (not a <ul>), and each nav.Item is a plain link, not a tab — these links navigate rather than switch an in-page panel. TabNav needs an accessible name from @label, or pass aria-labelledby on TabNav directly.

Every link stays in the natural tab order and arrow keys are left to the browser; there is no roving tabindex and no keyboard handling to document beyond ordinary link navigation. The active link carries aria-current="page" and data-selected="true", both a nav.Item derives and {{nav.setupItem}} sets for a hand-rolled link — a disabled link instead carries aria-disabled and drops its href.

API

TabNav

Element: HTMLElement

A navigation bar styled like Tabs, for links that change the page.

It deliberately does not use rovingFocus: these are links, so every one of them stays individually reachable by Tab and the arrow keys are left to the browser. The ARIA tabs pattern covers in-page panel switching only, and applying it to navigation would remove links from the tab order for no gain.

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'indicator' | 'list' | 'tab' | 'panel'> - Class names for each slot of the component, merged with the theme's.
color enum 'neutral' The colour intent applied to the indicator.
isFullWidth boolean false Stretches the bar to its container and gives every link equal width.
label string - Accessible name for the navigation landmark.
orientation enum 'horizontal' Lays the links out in a row or a column.
size enum 'md' The size of the links, driving padding and text size.
variant enum 'solid' The visual style of the navigation bar.

Blocks

Name Type Default Description
default * Array -

TabNavItem

Element: HTMLAnchorElement

Arguments

Name Type Default Description
itemClass * string - Supplied by TabNav. Not part of the public API.
setupItem * ModifierLike<{ Element: HTMLElement; Args: { Positional: [boolean]; }; }> - Supplied by TabNav. Not part of the public API.
class string - Class names appended to this item's theme classes.
href string - Renders a plain anchor. Ignored when @route is given.
isActive boolean - Overrides the active state. Always wins over anything derived from the router, and is the only source of truth when @route is not used.
isDisabled boolean false Marks the link as disabled. An anchor cannot be natively disabled, so the href is dropped as well -- aria-disabled alone still leaves it clickable.
model unknown - A single dynamic segment for @route.
models Array - Dynamic segments for @route.
query Record<string, unknown> - Query params for @route.
route string - Renders a LinkTo for this route and derives the active state from the router. Omit it (and pass @href) to stay entirely router-free.

Blocks

Name Type Default Description
default * Array -
Released under MIT License - Created by Josemar Luedke