Elements

Switch

A switch turns a setting on or off, and the change takes effect at once. It owns the behavior: presses, keyboard activation, the disabled state, and the switch role and its value for screen readers. The look is the app’s: a Switch has no colors, sizes, motion or label of its own. It is made of a track with a thumb inside it, then its children (a label), and the app styles each part, with checked: and disabled: classes that follow the switch.

el-switch.tsx
el-switch.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Switch } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// The parts: geometry and motion in classes (`checked:` moves the thumb,
// `disabled:` dims the row, both following the switch), colors from the
// palette by the value the app owns. A pointer gets the compact size; the
// pointer's hover and the keyboard's focus ring come from the palette, and
// the thumb and track stay still with reduced motion.
const row = `flex-row-reverse items-center justify-between gap-3 ${desktop ? "px-3 py-2" : "px-4 py-3"} active:opacity-70 disabled:opacity-40`;
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";

function Setting(props: { label: string; detail: string; checked: boolean; disabled?: boolean; onChange: (next: boolean) => void }) {
  return (
    <Switch
      checked={props.checked}
      disabled={props.disabled}
      onChange={props.onChange}
      class={row}
      hoverStyle={props.disabled ? undefined : hoverFill()}
      focusStyle={focusRing()}
      trackClass={track}
      trackStyle={props.checked ? ui().accentFill : ui().inset}
      thumbClass={thumb}
      thumbStyle={props.checked ? { backgroundColor: ui().onAccent } : ui().raised}
    >
      <View style={{ flex: 1, gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
    </Switch>
  );
}

function App() {
  const wifi = signal(true);
  const bluetooth = signal(false);
  const ask = signal(true);
  const events = signal<string[]>([]);
  const change = (name: string, value: { set: (v: boolean) => void }) => (next: boolean) => {
    value.set(next);
    events.set([`${name} → ${next}`, ...events()].slice(0, 3));
  };
  const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>switch</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a row, or Tab to a switch and press Space" : "Tap a row: the label toggles it too"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="checked · disabled">
              <Text style={{ fontSize: 13, color: ui().text }}>
                Wi-Fi {wifi() ? "on" : "off"} · ask {ask() ? "on" : "off"}
                {wifi() ? "" : " (disabled)"} · Bluetooth {bluetooth() ? "on" : "off"}
              </Text>
            </Panel>
            <Log title="onChange(checked)" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Setting label="Wi-Fi" detail={wifi() ? "Connected to Studio" : "Off"} checked={wifi()} onChange={change("Wi-Fi", wifi)} />
          {line()}
          <Setting
            label="Ask to join networks"
            detail={wifi() ? "Known networks join on their own" : "Turn on Wi-Fi first"}
            checked={ask()}
            disabled={!wifi()}
            onChange={change("Ask to join", ask)}
          />
          {line()}
          <Setting label="Bluetooth" detail={bluetooth() ? "Discoverable as “Desk”" : "Off"} checked={bluetooth()} onChange={change("Bluetooth", bluetooth)} />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{switch, theme};
use nsh_core::ui::prelude::*;

fn wifi() -> impl IntoElement {
    let on = signal(false);
    switch()
        .checked(on)
        .on_change(move |next: &bool| on.set(*next))
        .flex_row()
        .items_center()
        .gap_3()
        .track_style(|s| {
            s.w(px(44.0))
                .p(px(2.0))
                .rounded_full()
                .bg(move || theme().muted)
                .transition_colors()
                .checked(|s| s.bg(move || theme().primary))
        })
        .thumb_style(|s| {
            s.size(px(20.0))
                .rounded_full()
                .bg(move || theme().background)
                .shadow_sm()
                .transition_transform()
                .checked(|s| s.translate_x(px(20.0)))
        })
        .child(text("Wi-Fi"))
}

Switch comes from nsh/base, the nsh-base crate in Rust. The value is the app’s: a press reports the next value with onChange, and the switch shows the checked the app passes back. In Rust, switch() makes one with a setter per prop (.checked(..), .track_style(..)), a listener per event (.on_change(..)), the typed style methods for the switch itself and .child(..) for its label; a part’s style takes the state methods (.checked(..), .disabled_style(..)) that follow the switch.

#Props

PropRustTypeDefaultDescription
checked.checked(..)booleanfalseOn or off. A press reports the next value with onChange; the switch shows the value the app passes back.
disabled.disabled(..)booleanfalseNo presses and no press feedback; disabled: styles apply to the switch and its parts, and screen readers say it is dimmed.
checkedStyle.checked_style(|s| ..)StyleProp<ViewStyle>noneThe switch’s style while checked and not disabled, as checked: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say: what the switch turns on, when its children have no text that says it.
trackClass, trackStyle.track_style(|s| ..)string, StyleProp<ViewStyle>noneThe track, the part the thumb moves along.
thumbClass, thumbStyle.thumb_style(|s| ..)string, StyleProp<ViewStyle>noneThe thumb, inside the track.
children.child(..)JSX.ElementnoneThe content after the track: a label, which screen readers say.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe switch itself: the row around the track and the label.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe switch’s style while pressed, hovered, focused or disabled, as the <state>: classes.

#Events

EventRustDescription
onChange.on_change(|checked: &bool| ..)The switch was pressed: a tap, a click, Space or Return while it has the keyboard focus, or a screen reader’s activation. It reports the value the switch asks for, the opposite of checked.

A disabled switch reports nothing.

#Parts

PartInsideStyled withStates
The switchclass, stylechecked:, disabled:, active:, hover:, focus: (or checkedStyle, disabledStyle, …)
The trackthe switch, firsttrackClass, trackStylechecked:, disabled: follow the switch
The thumbthe trackthumbClass, thumbStylechecked:, disabled: follow the switch
The labelthe switch, after the trackits own elements (children)group-checked: and the other group-*: variants with group on the switch’s class

The thumb moves by its own checked: styles: a translation (checked:translate-x-5), or a margin or the track’s alignment (checked:justify-end on the track). A transition-* class animates the change natively. To place the label before the track, reverse the row: class="flex-row-reverse justify-between".

#Disabled

A disabled switch takes no presses, and screen readers announce it as dimmed. Its disabled: classes and those of its track and thumb apply, over their checked: ones:

<Switch
  checked={on()}
  disabled={!online()}
  onChange={(next) => on.set(next)}
  class="flex-row items-center gap-3 disabled:opacity-50"
  trackClass="w-11 rounded-full bg-muted p-0.5 checked:bg-primary disabled:bg-muted"
  thumbClass="size-5 rounded-full bg-background checked:translate-x-5"
>
  <Text>Sync over cellular</Text>
</Switch>

#Accessibility

  • A switch is one element for screen readers, with the role "switch" and its value (on or off); the track and the thumb are parts of it. Its label is the text of its children; give accessibilityLabel when it has none.
  • disabled is announced as the platform announces it (VoiceOver, TalkBack, UI Automation, the browser’s accessibility tree).
  • On macOS and the web a switch is a stop of the keyboard focus: Tab reaches it, Space and Return toggle 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 switch, 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 toggle the switch that has the keyboard focus.
  • Every platform draws what the app styles; the thumb moves as the app’s styles and transitions say.