Elements

Tabs

Tabs switch between the sections of one place: the overview and the activity of a project, the views of a document. Each Tab owns the behavior of one tab: presses, keyboard activation, the disabled state, and the tab role and its selected state for screen readers. A Tabs around them is the tab list, which screen readers announce as one set. The look is the app’s: a tab has no colors, sizes or label of its own. Its children are the app’s label, and selected: classes on the tab, or group-selected: classes on what is inside it, follow its state. The content of each section is the app’s too: it shows the one its value names, next to the list. The list’s indicator, an underline or a pill the app styles, sits on the selected tab and travels to the next one natively (Indicator).

el-tabs.tsx
el-tabs.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Tab, Tabs } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, size, Split, tint, ui } from "./palette";

// What each section shows: the app switches the content by its own value.
const sections = [
  {
    id: "overview",
    label: "Overview",
    rows: [
      ["Visitors", "12,480"],
      ["Sign-ups", "342"],
      ["Conversion", "2.74%"],
    ],
  },
  {
    id: "activity",
    label: "Activity",
    rows: [
      ["Deploy to production", "09:41"],
      ["Invoice paid", "08:15"],
      ["New member joined", "07:02"],
    ],
  },
  {
    id: "settings",
    label: "Settings",
    rows: [
      ["Region", "Frankfurt"],
      ["Plan", "Pro"],
      ["Seats", "8 of 10"],
    ],
  },
];
const folders = [
  { id: "inbox", label: "Inbox", count: 12 },
  { id: "archive", label: "Archive", count: 0 },
  { id: "spam", label: "Spam", count: 3, locked: true },
];

function App() {
  const section = signal("overview");
  const folder = signal("inbox");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 3));
  const current = () => sections.find((s) => s.id === section())!;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tabs</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a tab, or Tab to a list and move with the arrow keys" : "Tap a tab to show its section"}
      </Text>

      <Split gap={14} inspector={<Log title={`onPress · ${section()}, ${folder()}`} lines={events()} minHeight={76} />}>
        {/* A segmented tab list: each tab is a segment, and the list's indicator is the raised one, inside the
            list's padding, which springs to the tab the app selects. */}
        <Tabs
          style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}
          indicatorStyle={{ top: 3, bottom: 3, borderRadius: 7, ...ui().raised }}
        >
          <Each of={sections}>
            {(s) => (
              <Tab
                selected={section() === s().id}
                onPress={() => {
                  section.set(s().id);
                  log(`${s().label} pressed`);
                }}
                style={
                  desktop
                    ? { height: 26, paddingHorizontal: 14, borderRadius: 7, alignItems: "center", justifyContent: "center" }
                    : { flex: 1, height: 30, borderRadius: 7, alignItems: "center", justifyContent: "center" }
                }
                class="active:opacity-70"
                hoverStyle={section() === s().id ? undefined : hoverFill()}
                focusStyle={focusRing()}
              >
                <Text style={{ fontSize: 13, fontWeight: "500", color: section() === s().id ? ui().text : ui().muted }}>
                  {s().label}
                </Text>
              </Tab>
            )}
          </Each>
        </Tabs>

        {/* The selected section's content, which the app shows by its value. */}
        <View style={{ ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <Each of={current().rows}>
            {(row, i) => (
              <>
                {i() > 0 ? <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} /> : null}
                <View
                  style={
                    desktop
                      ? { flexDirection: "row", alignItems: "center", paddingHorizontal: 12, height: size.row }
                      : { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12 }
                  }
                >
                  <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{row()[0]}</Text>
                  <Text style={{ fontSize: 14, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().muted }}>{row()[1]}</Text>
                </View>
              </>
            )}
          </Each>
        </View>

        {/* Tabs with counts: the selected one's own fill, a disabled one dimmed. */}
        <Tabs style={{ flexDirection: "row", gap: 6 }}>
          <Each of={folders}>
            {(f) => (
              <Tab
                selected={folder() === f().id}
                disabled={f().locked}
                onPress={() => {
                  folder.set(f().id);
                  log(`${f().label} pressed`);
                }}
                class="active:opacity-70 disabled:opacity-40"
                hoverStyle={f().locked || folder() === f().id ? undefined : hoverFill()}
                focusStyle={focusRing()}
                style={{ flexDirection: "row", alignItems: "center", gap: 6, height: desktop ? size.controlSm : 30, paddingHorizontal: desktop ? 10 : 12, borderRadius: 8 }}
                selectedStyle={{ backgroundColor: tint(ui().accent, 0.16) }}
              >
                <Text style={{ fontSize: 13, fontWeight: "500", color: folder() === f().id ? ui().accent : ui().muted }}>
                  {f().label}
                </Text>
                {f().count > 0 ? (
                  <Text style={{ fontSize: 12, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().faint }}>{f().count}</Text>
                ) : null}
              </Tab>
            )}
          </Each>
        </Tabs>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{tab, tabs, theme};
use nsh_core::prelude::*;

fn sections() -> impl IntoElement {
    let section = signal("overview");
    let item = move |value: &'static str, label: &'static str| {
        let selected = move || section.get() == value;
        tab()
            .selected(selected)
            .on_press(move |_: &()| section.set(value))
            .rounded(move || theme().radius)
            .px_3()
            .py_2()
            .child(text(label).text_sm().text_color(move || {
                if selected() { theme().foreground } else { theme().muted_foreground }
            }))
    };
    view()
        .gap_4()
        .child(
            tabs()
                .flex_row()
                .self_start()
                .gap_1()
                .p_1()
                .rounded(move || theme().radius)
                .bg(move || theme().muted)
                .indicator_style(|s| {
                    s.top_1()
                        .bottom_1()
                        .rounded(move || theme().radius)
                        .bg(move || theme().background)
                })
                .child(item("overview", "Overview"))
                .child(item("activity", "Activity")),
        )
        .child(text(move || {
            if section.get() == "overview" {
                "Visitors, sign-ups and conversion this week."
            } else {
                "Deploys, invoices and new members, newest first."
            }
        }).text_color(move || theme().foreground))
}

Tab and Tabs come from nsh/base, the nsh-base crate in Rust. The value is the app’s: the list holds none. Each tab is told whether it is selected; a press reports onPress, the app sets its value and shows that section, and every tab shows the selected the app passes back. The list’s corners and the tabs’ are concentric: the list’s padding (p-1) is the difference between radius.lg and radius. The selected tab’s fill is the list’s indicator (indicatorClass, inside that padding: inset-y-1), which springs to the tab the app selects. In Rust, tab() and tabs() make them, with a setter per prop (.selected(..)), a listener per event (.on_press(..)), the typed style methods and .child(..) for their content.

#Props

#Tab

PropRustTypeDefaultDescription
selected.selected(..)booleanfalseThe current tab of its list: screen readers say it is selected, and selected: styles apply. A press reports onPress; the tab shows the value the app passes back.
disabled.disabled(..)booleanfalseNo presses and no press feedback; disabled: styles apply, and screen readers say it is dimmed.
selectedStyle.selected_style(|s| ..)StyleProp<ViewStyle>noneThe tab’s style while selected and not disabled, as selected: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say: the tab’s name, when its children have no text that says it (an icon).
children.child(..)JSX.ElementnoneThe app’s label, icon or badge, which screen readers say.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe tab itself: the surface around its label.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe tab’s style while pressed, hovered, focused or disabled, as the <state>: classes.

#Tabs

PropRustTypeDefaultDescription
indicatorClass.indicator_class(..)stringnoneThe Tailwind classes of the indicator on the selected tab: its look and its place across the list (bottom-0 h-0.5 for an underline in a row, inset-y-1 for a pill). Along the list it spans the selected tab, inside its own margins on that axis. With indicatorClass or indicatorStyle the list has an indicator.
indicatorStyle.indicator_style(|s| ..)StyleProp<ViewStyle>noneThe style of the indicator, over indicatorClass.
children.child(..)JSX.ElementnoneThe tabs of the list, and whatever the app lays out between them.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe list’s layout and look: a column by default, flex-row for a row.

#Events

EventRustDescription
onPress (Tab).on_press(|_: &()| ..)The tab was pressed: a tap, a click, Space or Return while it has the keyboard focus, or a screen reader’s activation; or an arrow key, Home or End of its list moved the keyboard focus to it. It runs before anything changes: the tab shows the selected the app passes back.

The selected tab reports its presses too, so the app can act on a second press (scroll its section to the top, say). A disabled tab reports nothing.

#Parts

PartInsideStyled withStates
The listTabs’ class, style
The indicatorthe list, behind the tabsindicatorClass, indicatorStyleon the selected tab
The tabthe listTab’s class, styleselected:, disabled:, active:, hover:, focus: (or selectedStyle, disabledStyle, …)
The labelthe tabits own elements (children)group-selected:, group-disabled: and the other group-*: variants with group on the tab’s class
The section’s contentnext to the listthe app’s elementsthe app’s value

Show the selected tab through its own surface: a fill, a stronger label, a heavier weight (see Design guides).

#Indicator

An underline under the selected tab, a pill behind it or a segmented control’s raised segment is the list’s indicator: a view the app styles with indicatorClass and indicatorStyle, drawn behind the tabs and absolutely positioned in the list. Native Shell places it on the selected tab after each layout. Along the list (a row’s width, a column’s height) it spans that tab, inside the indicator’s own margins on that axis (mx-3 for an underline shorter than its tab); across the list its classes place it:

// An underline at the bottom of a row of tabs.
<Tabs class="flex-row gap-4 border-b border-border" indicatorClass="bottom-0 h-0.5 bg-foreground">
  {item("overview", "Overview")}
  {item("activity", "Activity")}
</Tabs>

// A pill behind the selected tab, inside the list's padding.
<Tabs class="flex-row self-start gap-1 rounded-(--radius-lg) bg-muted p-1" indicatorClass="inset-y-1 rounded-(--radius) bg-background shadow-sm">
  {item("overview", "Overview")}
  {item("activity", "Activity")}
</Tabs>

When the app selects another tab, the indicator springs there natively, as GPUI Kit’s tab bar indicator does; with reduced motion it moves at once. While no tab is selected it is hidden; while several are, it is on the first. A resize or a change of a tab’s width moves it in the same frame as the tabs. The indicator needs no layout events and no code of the app’s: the selected tab is the one whose selected is true. In a list that scrolls, the scroll view goes around the Tabs, and the indicator scrolls with the tabs.

#Disabled

A disabled tab takes no presses, and screen readers announce it as dimmed. Its disabled: classes apply over its selected: ones, and group-disabled: classes inside it follow:

<Tab
  selected={section() === "billing"}
  disabled={!isOwner()}
  onPress={() => section.set("billing")}
  class="group rounded-(--radius) px-3 py-2 selected:bg-background disabled:opacity-50"
>
  <Text class="text-sm text-muted-foreground group-selected:text-foreground">Billing</Text>
</Tab>

#Accessibility

  • A tab is one element for screen readers, with the role "tab" and its selected state. Its label is the text of its children, or accessibilityLabel.
  • Tabs has the role "tablist": screen readers hear the tabs inside it as one set, each still an element of its own.
  • disabled is announced as the platform announces it (VoiceOver, TalkBack, UI Automation, the browser’s accessibility tree).
  • On macOS and the web the list is one stop of the keyboard focus, and the arrow keys move between its tabs (below).
  • The indicator is hidden from screen readers and takes no focus: the tabs alone are the list’s elements.

See Accessibility for labels, roles and announcements in general.

#Keyboard

On macOS and the web, where Tab reaches the views that take presses, a Tabs list follows the WAI-ARIA tabs pattern:

KeyWhat it does
Tab, Shift+TabEnters the list at the selected tab (the first tab that is not disabled while none is), and leaves it in one step.
Right, DownMoves the focus to the next tab, from the last to the first.
Left, UpMoves the focus to the previous tab, from the first to the last.
Home, EndMoves the focus to the first or the last tab.
Space, ReturnPresses the focused tab.

The arrow keys skip disabled tabs, and the tab they move to reports onPress, so the app selects it as the focus moves: the section follows the focus. Both pairs of arrows move, whatever the list’s layout, which is the app’s class. In a right-to-left layout the row runs from the right, so Left and Right trade places: Left moves to the next tab and Right to the previous; Up, Down, Home and End keep theirs. focus: styles show where the focus is. A Tab outside a Tabs is a stop of its own.

#Platform notes

  • iOS, Android and Mac Catalyst: a press is a tap that ends on the tab, its label included.
  • Desktops (macOS, Windows, Linux, GPUI) and the web: a press is a click. On macOS and the web, Space and Return also press the tab that has the keyboard focus, and the arrow keys, Home and End move it (Keyboard).
  • Every platform draws what the app styles: the tabs, the selected one’s surface, the indicator and the sections’ content. The indicator’s travel is a native layout animation on every platform.