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.
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))
}import { mount, signal, Text, View } from "nsh";
import { Tab, Tabs } from "nsh/base";
function App() {
const section = signal("overview");
const item = (value: string, label: string) => (
<Tab
selected={section() === value}
onPress={() => section.set(value)}
class="group rounded-(--radius) px-3 py-2 selected:bg-background"
>
<Text class="text-sm text-muted-foreground group-selected:text-foreground">{label}</Text>
</Tab>
);
return (
<View class="gap-4">
<Tabs class="flex-row self-start gap-1 rounded-(--radius-lg) bg-muted p-1">
{item("overview", "Overview")}
{item("activity", "Activity")}
</Tabs>
<Text class="text-foreground">
{section() === "overview"
? "Visitors, sign-ups and conversion this week."
: "Deploys, invoices and new members, newest first."}
</Text>
</View>
);
}
mount(App);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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
selected | .selected(..) | boolean | false | The 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(..) | boolean | false | No presses and no press feedback; disabled: styles apply, and screen readers say it is dimmed. |
selectedStyle | .selected_style(|s| ..) | StyleProp<ViewStyle> | none | The tab’s style while selected and not disabled, as selected: classes. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers say: the tab’s name, when its children have no text that says it (an icon). |
children | .child(..) | JSX.Element | none | The app’s label, icon or badge, which screen readers say. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The tab itself: the surface around its label. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | none | The tab’s style while pressed, hovered, focused or disabled, as the <state>: classes. |
#Tabs
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
children | .child(..) | JSX.Element | none | The tabs of the list, and whatever the app lays out between them. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The list’s layout and look: a column by default, flex-row for a row. |
#Events
| Event | Rust | Description |
|---|---|---|
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
| Part | Inside | Styled with | States |
|---|---|---|---|
| The list | Tabs’ class, style | ||
| The tab | the list | Tab’s class, style | selected:, disabled:, active:, hover:, focus: (or selectedStyle, disabledStyle, …) |
| The label | the tab | its own elements (children) | group-selected:, group-disabled: and the other group-*: variants with group on the tab’s class |
| The section’s content | next to the list | the app’s elements | the 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, oraccessibilityLabel. Tabshas the role"tablist": screen readers hear the tabs inside it as one set, each still an element of its own.disabledis 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.