Elements
Toggle
A toggle is a button that stays on or off: bold in a text toolbar, a favorite star, a view option. Toggle owns the
behavior of one: presses, keyboard activation, the disabled state, and the button role with its pressed state for
screen readers, which announce it as a toggle button. A ToggleGroup around several is a toolbar: screen readers
announce it as one, and the keyboard reaches it in one stop and moves between its toggles with the arrow keys. The
look is the app’s: a toggle has no colors, sizes, icon or label of its own. Its children are the app’s icon or
label, and aria-pressed: classes on the toggle, or group-aria-pressed: classes on what is inside it, follow its
state.
import { Canvas, mount, signal, Text, View } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Split, tint, ui } from "./palette";
type Mark = "bold" | "italic" | "underline";
type Align = "left" | "center" | "right";
const marks: { id: Mark; label: string; glyph: string }[] = [
{ id: "bold", label: "Bold", glyph: "B" },
{ id: "italic", label: "Italic", glyph: "I" },
{ id: "underline", label: "Underline", glyph: "U" },
];
const aligns: { id: Align; label: string }[] = [
{ id: "left", label: "Align left" },
{ id: "center", label: "Align center" },
{ id: "right", label: "Align right" },
];
/** Three lines of a paragraph, set as `align` sets them. */
function Lines(props: { align: Align; color: string }) {
return (
<Canvas
style={{ pointerEvents: "none", width: 16, height: 16 }}
draw={(cx) => {
cx.strokeStyle = props.color;
cx.lineWidth = 1.6;
cx.lineCap = "round";
[12, 7, 10].forEach((w, i) => {
const x = props.align === "center" ? 8 - w / 2 : props.align === "right" ? 14 - w : 2;
cx.beginPath();
cx.moveTo(x, 4 + i * 4);
cx.lineTo(x + w, 4 + i * 4);
cx.stroke();
});
}}
/>
);
}
function App() {
const on = signal<Mark[]>(["bold"]);
const align = signal<Align>("left");
const starred = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 3));
const has = (m: Mark) => on().includes(m);
// A toolbar toggle: bare while off, raised while pressed (`pressedStyle`).
const look = (pressed: boolean) => ({
style: { width: desktop ? 28 : 34, height: desktop ? 28 : 34, borderRadius: 7, alignItems: "center", justifyContent: "center" } as const,
class: "transition-colors active:opacity-70 motion-reduce:transition-none",
pressedStyle: ui().raised,
hoverStyle: pressed ? undefined : hoverFill(),
focusStyle: focusRing(),
});
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toggle</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click a toggle, or Tab to the toolbar and move with the arrow keys" : "Tap a toggle to turn it on or off"}
</Text>
<Split gap={14} inspector={<Log title={`onChange · ${on().join(", ") || "none"}, ${align()}`} lines={events()} minHeight={76} />}>
{/* One toolbar: the marks any number at once, the alignment exactly one. */}
<ToggleGroup style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 2, padding: 3, borderRadius: 10, ...ui().inset }}>
<Each of={marks}>
{(m) => (
<Toggle
pressed={has(m().id)}
accessibilityLabel={m().label}
onChange={({ pressed }) => {
on.set(pressed ? [...on(), m().id] : on().filter((x) => x !== m().id));
log(`${m().label}: ${pressed}`);
}}
{...look(has(m().id))}
>
<Text
style={{
fontSize: 14,
color: has(m().id) ? ui().text : ui().muted,
fontWeight: m().id === "bold" ? "800" : "500",
fontStyle: m().id === "italic" ? "italic" : "normal",
textDecorationLine: m().id === "underline" ? "underline" : "none",
}}
>
{m().glyph}
</Text>
</Toggle>
)}
</Each>
<View style={{ width: 1, height: 16, marginHorizontal: 4, backgroundColor: ui().line }} />
<Each of={aligns}>
{(a) => (
<Toggle
pressed={align() === a().id}
accessibilityLabel={a().label}
onChange={({ pressed }) => {
// Exactly one: the pressed alignment stays pressed.
if (pressed) align.set(a().id);
log(`${a().label}: ${pressed}`);
}}
{...look(align() === a().id)}
>
<Lines align={a().id} color={align() === a().id ? ui().text : ui().muted} />
</Toggle>
)}
</Each>
</ToggleGroup>
{/* The text the toolbar formats: the app reads its own values. */}
<View style={{ ...ui().card, borderRadius: 14, padding: 14 }}>
<Text
style={{
fontSize: 15,
lineHeight: 22,
color: ui().text,
fontWeight: has("bold") ? "700" : "400",
fontStyle: has("italic") ? "italic" : "normal",
textDecorationLine: has("underline") ? "underline" : "none",
textAlign: align(),
}}
>
Every toggle reports the value it asks for; the app passes it back.
</Text>
</View>
{/* A toggle on its own, with a label: `aria-pressed:` tints it. */}
<Toggle
pressed={starred()}
onChange={({ pressed }) => {
starred.set(pressed);
log(`Star: ${pressed}`);
}}
class="transition-colors active:opacity-70 motion-reduce:transition-none"
style={{ flexDirection: "row", alignItems: "center", alignSelf: "flex-start", gap: 6, height: desktop ? 28 : 34, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: ui().line }}
pressedStyle={{ backgroundColor: tint(ui().accent, 0.16), borderColor: tint(ui().accent, 0.4) }}
hoverStyle={starred() ? undefined : hoverFill()}
focusStyle={focusRing()}
>
<Text style={{ fontSize: 14, color: starred() ? ui().accent : ui().muted }}>{starred() ? "★" : "☆"}</Text>
<Text style={{ fontSize: 13, fontWeight: "500", color: starred() ? ui().accent : ui().text }}>{starred() ? "Starred" : "Star"}</Text>
</Toggle>
</Split>
</View>
);
}
mount(App);use nsh_base::{theme, toggle, toggle_group};
use nsh_core::prelude::*;
fn formatting() -> impl IntoElement {
let bold = signal(true);
let italic = signal(false);
let item = move |label: &'static str, on: Signal<bool>| {
toggle()
.pressed(on)
.accessibility_label(label)
.on_change(move |pressed: &bool| on.set(*pressed))
.size_8()
.items_center()
.justify_center()
.rounded(move || theme().radius)
.pressed_style(|s| s.bg(move || theme().accent))
.child(text(&label[..1]).text_sm().text_color(move || {
if on.get() { theme().accent_foreground } else { theme().muted_foreground }
}))
};
toggle_group()
.flex_row()
.self_start()
.gap_1()
.child(item("Bold", bold))
.child(item("Italic", italic))
}import { mount, signal, Text } from "nsh";
import { Toggle, ToggleGroup } from "nsh/base";
function App() {
const marks = signal<string[]>(["bold"]);
const item = (value: string, label: string) => (
<Toggle
pressed={marks().includes(value)}
accessibilityLabel={label}
onChange={({ pressed }) =>
marks.set(pressed ? [...marks(), value] : marks().filter((m) => m !== value))
}
class="group size-8 items-center justify-center rounded-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm text-muted-foreground group-aria-pressed:text-accent-foreground">{label[0]}</Text>
</Toggle>
);
return (
<ToggleGroup class="flex-row self-start gap-1">
{item("bold", "Bold")}
{item("italic", "Italic")}
</ToggleGroup>
);
}
mount(App);Toggle and ToggleGroup come from nsh/base, the nsh-base crate in Rust. The value is
the app’s: each toggle is told whether it is pressed, a press reports the value it asks for with onChange, and the
toggle shows the pressed the app passes back. The group holds no value either, so how many toggles may be pressed
is the app’s too: any number (bold, italic and underline), or exactly one (an alignment, a view switcher), where the
app presses the toggle asked for, lets go of the others and keeps the pressed one pressed. In Rust, toggle() and
toggle_group() make them, with a setter per prop (.pressed(..)), a listener per event (.on_change(..)), the
typed style methods and .child(..) for their content.
#Props
#Toggle
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
pressed | .pressed(..) | boolean | false | On or off: screen readers say it is pressed, and aria-pressed: styles apply. A press reports the other value with onChange; the toggle 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. A toggle in a disabled ToggleGroup is disabled too. |
pressedStyle | .pressed_style(|s| ..) | StyleProp<ViewStyle> | none | The toggle’s style while pressed and not disabled, as aria-pressed: classes. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers say: what the toggle turns on, when its children have no text that says it (an icon). |
children | .child(..) | JSX.Element | none | The app’s icon or label, which screen readers say. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The toggle itself: the surface around its content. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | none | The toggle’s style while a press is in progress, hovered, focused or disabled, as the <state>: classes. |
#ToggleGroup
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | Every toggle in the group is disabled, whatever its own disabled. |
children | .child(..) | JSX.Element | none | The toggles of the group, and whatever the app lays out between them (a separator). |
class, style | style methods | string, StyleProp<ViewStyle> | none | The group’s layout and look: a column by default, flex-row for a row. |
#Events
| Event | Rust | Description |
|---|---|---|
onChange (Toggle) | .on_change(|pressed: &bool| ..) | The toggle was pressed: a tap, a click, Space or Return while it has the keyboard focus, or a screen reader’s activation. { pressed } is the value it asks for, the opposite of pressed. It runs before anything changes: the toggle shows the pressed the app passes back. |
An arrow key of a ToggleGroup only moves the keyboard focus: it presses nothing. A disabled toggle reports nothing.
#Parts
| Part | Inside | Styled with | States |
|---|---|---|---|
| The group | ToggleGroup’s class, style | ||
| The toggle | the group, or anywhere | Toggle’s class, style | aria-pressed:, disabled:, active:, hover:, focus: (or pressedStyle, disabledStyle, …) |
| The icon or label | the toggle | its own elements (children) | group-aria-pressed:, group-disabled: and the other group-*: variants with group on the toggle’s class |
aria-pressed: is the toggle being on; active: is a press in progress, on or off. Show a pressed toggle through its
own surface: a fill, a raised pill, a stronger icon (see Design guides).
#Segmented
A group whose toggles are joined, one pressed at a time, is a segmented control (a view switcher): the app keeps one value and presses the toggle that names it. The joined look is the app’s classes:
<ToggleGroup class="flex-row self-start">
<Each of={["grid", "list"]}>
{(value) => (
<Toggle
pressed={view() === value()}
accessibilityLabel={`${value()} view`}
onChange={({ pressed }) => pressed && view.set(value())}
class="-ml-px h-8 px-3 border border-border first:ml-0 first:rounded-l-(--radius) last:rounded-r-(--radius) aria-pressed:bg-accent"
>
<Text class="text-sm">{value()}</Text>
</Toggle>
)}
</Each>
</ToggleGroup>
#Disabled
A disabled toggle takes no presses, and screen readers announce it as dimmed. Its disabled: classes apply over its
aria-pressed: ones, and group-disabled: classes inside it follow. disabled on a ToggleGroup disables every
toggle in it, a toolbar the app locks:
<ToggleGroup disabled={readonly()} class="flex-row gap-1">
<Toggle pressed={bold()} onChange={({ pressed }) => bold.set(pressed)} class="size-8 disabled:opacity-50">
<Text>B</Text>
</Toggle>
</ToggleGroup>
#Accessibility
- A toggle is one element for screen readers, with the role
"button"and its pressed state: a toggle button. Its label is the text of its children, oraccessibilityLabel. ToggleGrouphas the role"toolbar": screen readers hear the toggles inside it as one toolbar, 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 the group is one stop of the keyboard focus, and the arrow keys move between its toggles (below).
See Accessibility for labels, roles and announcements in general.
#Keyboard
On macOS and the web, where Tab reaches the views that take presses, a ToggleGroup follows the
WAI-ARIA toolbar pattern:
| Key | What it does |
|---|---|
| Tab, Shift+Tab | Enters the group at the first pressed toggle (the first toggle that is not disabled while none is), and leaves it in one step. |
| Right, Down | Moves the focus to the next toggle, from the last to the first. |
| Left, Up | Moves the focus to the previous toggle, from the first to the last. |
| Home, End | Moves the focus to the first or the last toggle. |
| Space, Return | Presses the focused toggle. |
The arrow keys skip disabled toggles and press nothing: a toggle changes only when it is pressed. Both pairs of arrows
move, whatever the group’s layout, which is the app’s class. focus: styles show where the focus is. A Toggle
outside a ToggleGroup is a stop of its own.
#Platform notes
- iOS, Android and Mac Catalyst: a press is a tap that ends on the toggle, its content 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 toggle that has the keyboard focus, and the arrow keys, Home and End move it (Keyboard).
- Every platform draws what the app styles: the toggles, the pressed ones’ surface and the group’s track.