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, indicator 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.

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 one and press Space" : "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, `selectedStyle` raises the selected one. */}
        <Tabs style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}>
          <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()}
                selectedStyle={ui().raised}
              >
                <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::ui::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()
            .selected_style(|s| s.bg(move || theme().background))
            .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)
                .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. 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
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. 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 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).

#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 each tab is a stop of the keyboard focus: Tab reaches it, Space and Return press it, and focus: styles show where the focus is.

See Accessibility for labels, roles and announcements in general.

#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.
  • Every platform draws what the app styles: the tabs, the selected one’s surface and the sections’ content.