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.

el-toggle.tsx
el-toggle.tsxLive
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))
}

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

PropRustTypeDefaultDescription
pressed.pressed(..)booleanfalseOn 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(..)booleanfalseNo 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>noneThe toggle’s style while pressed and not disabled, as aria-pressed: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say: what the toggle turns on, when its children have no text that says it (an icon).
children.child(..)JSX.ElementnoneThe app’s icon or label, which screen readers say.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe toggle itself: the surface around its content.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe toggle’s style while a press is in progress, hovered, focused or disabled, as the <state>: classes.

#ToggleGroup

PropRustTypeDefaultDescription
disabled.disabled(..)booleanfalseEvery toggle in the group is disabled, whatever its own disabled.
children.child(..)JSX.ElementnoneThe toggles of the group, and whatever the app lays out between them (a separator).
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe group’s layout and look: a column by default, flex-row for a row.

#Events

EventRustDescription
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

PartInsideStyled withStates
The groupToggleGroup’s class, style
The togglethe group, or anywhereToggle’s class, stylearia-pressed:, disabled:, active:, hover:, focus: (or pressedStyle, disabledStyle, …)
The icon or labelthe toggleits 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, or accessibilityLabel.
  • ToggleGroup has the role "toolbar": screen readers hear the toggles inside it as one toolbar, 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 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:

KeyWhat it does
Tab, Shift+TabEnters the group at the first pressed toggle (the first toggle that is not disabled while none is), and leaves it in one step.
Right, DownMoves the focus to the next toggle, from the last to the first.
Left, UpMoves the focus to the previous toggle, from the first to the last.
Home, EndMoves the focus to the first or the last toggle.
Space, ReturnPresses 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.