Elements

Checkbox

A checkbox selects an option, an item of a list, or all of them. It is checked, unchecked or indeterminate (neither: a “select all” over a partial selection). It owns the behavior: presses, keyboard activation, the disabled state, and the checkbox role and its value for screen readers. The look is the app’s: a Checkbox has no box, mark, colors, sizes or label of its own. It is made of an indicator (the box) with the app’s mark inside it, then its children (a label), and the app styles each part, with checked:, indeterminate: and disabled: classes that follow the checkbox.

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

// The parts: geometry in classes (`disabled:` dims the row, following the
// checkbox; `group-checked:` / `group-indeterminate:` show the mark of the
// indicator's state), colors from the palette by the value the app owns;
// the pointer's hover and the keyboard's focus ring from the palette too.
const row = `flex-row items-center gap-3 ${desktop ? "px-3 py-2" : "px-4 py-3"} active:opacity-70 disabled:opacity-40`;
const indicator = "group size-5 items-center justify-center rounded-md border transition-colors motion-reduce:transition-none";

/** The indicator's surface: the accent while checked or indeterminate. */
const box = (on: boolean) => (on ? ui().accentFill : { ...ui().inset, borderColor: ui().faint });

/** A check while checked, a dash while indeterminate. */
const mark = () => (
  <>
    <Text class="hidden text-xs font-bold group-checked:flex" style={{ color: ui().onAccent }}>
      ✓
    </Text>
    <View class="hidden h-0.5 w-2.5 rounded-full group-indeterminate:flex" style={{ backgroundColor: ui().onAccent }} />
  </>
);

const items = [
  { id: "photos", label: "Photos", detail: "2,418 items · 6.1 GB" },
  { id: "documents", label: "Documents", detail: "312 files · 840 MB" },
  { id: "messages", label: "Messages", detail: "Attachments included · 1.2 GB" },
];

function Label(props: { label: string; detail: string; strong?: boolean }) {
  return (
    <View style={{ flex: 1, gap: 2 }}>
      <Text style={{ fontSize: size.label, fontWeight: props.strong ? "600" : "500", color: ui().text }}>{props.label}</Text>
      <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
    </View>
  );
}

function App() {
  const selected = signal<string[]>(["photos"]);
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 3));
  const all = () => selected().length === items.length;
  const some = () => selected().length > 0 && !all();
  const line = () => <View style={{ height: 1, marginLeft: desktop ? 44 : 48, 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 }}>checkbox</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click a row, or Tab to a checkbox and press Space" : "Tap a row: the label toggles it too"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="checked · indeterminate">
              <Text style={{ fontSize: 13, color: ui().text }}>
                Back up everything: {all() ? "checked" : some() ? "indeterminate" : "unchecked"} · [{selected().join(", ")}]
              </Text>
            </Panel>
            <Log title="onChange(checked)" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Checkbox
            checked={all()}
            indeterminate={some()}
            onChange={(next) => {
              selected.set(next ? items.map((i) => i.id) : []);
              log(`Back up everything → ${next}`);
            }}
            class={row}
            hoverStyle={hoverFill()}
            focusStyle={focusRing()}
            indicatorClass={indicator}
            indicatorStyle={box(all() || some())}
            indicator={mark()}
          >
            <Label label="Back up everything" detail={`${selected().length} of ${items.length} selected`} strong />
          </Checkbox>
          <Each of={items}>
            {(item) => (
              <>
                {line()}
                <Checkbox
                  checked={selected().includes(item().id)}
                  onChange={(next) => {
                    selected.set(next ? [...selected(), item().id] : selected().filter((id) => id !== item().id));
                    log(`${item().label} → ${next}`);
                  }}
                  class={row}
                  hoverStyle={hoverFill()}
                  focusStyle={focusRing()}
                  indicatorClass={indicator}
                  indicatorStyle={box(selected().includes(item().id))}
                  indicator={mark()}
                >
                  <Label label={item().label} detail={item().detail} />
                </Checkbox>
              </>
            )}
          </Each>
        </View>

        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Checkbox checked disabled class={row} indicatorClass={indicator} indicatorStyle={box(true)} indicator={mark()}>
            <Label label="System settings" detail="Always backed up" />
          </Checkbox>
        </View>
      </Split>
    </View>
  );
}

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

fn notify() -> impl IntoElement {
    let on = signal(false);
    checkbox()
        .checked(on)
        .on_change(move |next: &bool| on.set(*next))
        .flex_row()
        .items_center()
        .gap_2()
        .indicator_style(|s| {
            s.size(px(20.0))
                .items_center()
                .justify_center()
                .rounded_md()
                .border_2()
                .border_color(move || theme().border)
                .transition_colors()
                .checked(|s| s.bg(move || theme().primary).border_color(move || theme().primary))
        })
        .indicator(text(move || if on.get() { "✓" } else { "" }).text_color(move || theme().primary_foreground))
        .child(text("Notify me"))
}

Checkbox 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 checkbox shows the checked and indeterminate the app passes back. In Rust, checkbox() makes one with a setter per prop (.checked(..), .indicator_style(..)), a listener per event (.on_change(..)), the typed style methods for the checkbox itself and .child(..) for its label; the indicator’s style takes the state methods (.checked(..), .indeterminate(..), .disabled_style(..)) that follow the checkbox.

#Props

PropRustTypeDefaultDescription
checked.checked(..)booleanfalseChecked or not. A press reports the next value with onChange; the checkbox shows the value the app passes back.
indeterminate.indeterminate(..)booleanfalseNeither checked nor unchecked, over checked while it holds: screen readers say it is mixed, and a press asks for checked.
disabled.disabled(..)booleanfalseNo presses and no press feedback; disabled: styles apply to the checkbox and its indicator, and screen readers say it is dimmed.
checkedStyle.checked_style(|s| ..)StyleProp<ViewStyle>noneThe checkbox’s style while checked and not disabled, as checked: classes.
indeterminateStyle.indeterminate_style(|s| ..)StyleProp<ViewStyle>noneThe checkbox’s style while indeterminate and not disabled, as indeterminate: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say: what the checkbox selects, when its children have no text that says it.
role.role(..)AccessibilityRole"checkbox"What it is for screen readers, when it is something more specific (a checked menu item: "menuitem").
indicatorClass, indicatorStyle.indicator_style(|s| ..)string, StyleProp<ViewStyle>noneThe indicator, the box that shows the value.
indicator.indicator(..)JSX.ElementnoneThe content of the indicator: the mark (a check, a dash), which the app shows by its value.
children.child(..)JSX.ElementnoneThe content after the indicator: a label, which screen readers say.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe checkbox itself: the row around the indicator and the label.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe checkbox’s style while pressed, hovered, focused or disabled, as the <state>: classes.

#Events

EventRustDescription
onChange.on_change(|checked: &bool| ..)The checkbox 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 checkbox asks for: true from unchecked and from indeterminate, false from checked.

A disabled checkbox reports nothing.

#Parts

PartInsideStyled withStates
The checkboxclass, stylechecked:, indeterminate:, disabled:, active:, hover:, focus: (or checkedStyle, indeterminateStyle, disabledStyle, …)
The indicatorthe checkbox, firstindicatorClass, indicatorStylechecked:, indeterminate:, disabled: follow the checkbox
The markthe indicatorits own elements (indicator)group-checked:, group-indeterminate: and the other group-*: variants with group on indicatorClass
The labelthe checkbox, after the indicatorits own elements (children)group-*: variants with group on the checkbox’s class

The mark is the app’s content, so it shows what the app decides: a check while checked and a dash while indeterminate, chosen from the value the app holds, or both marks with group-checked: / group-indeterminate: classes picking the one shown.

#Indeterminate

A “select all” checkbox is indeterminate while some items are selected. A press asks for checked, and the app selects every item:

const selected = signal<string[]>([]);
const all = () => selected().length === items.length;
const some = () => selected().length > 0 && !all();

<Checkbox
  checked={all()}
  indeterminate={some()}
  onChange={(next) => selected.set(next ? items.map((i) => i.id) : [])}
  class="flex-row items-center gap-2"
  indicatorClass="group size-5 items-center justify-center rounded-md border-2 border-border checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary"
  indicator={
    <>
      <Text class="hidden text-primary-foreground group-checked:flex">✓</Text>
      <View class="hidden h-0.5 w-2.5 bg-primary-foreground group-indeterminate:flex" />
    </>
  }
>
  <Text>Select all</Text>
</Checkbox>

#Disabled

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

<Checkbox
  checked={on()}
  disabled={locked()}
  onChange={(next) => on.set(next)}
  class="flex-row items-center gap-2 disabled:opacity-50"
  indicatorClass="size-5 rounded-md border-2 border-border checked:border-primary checked:bg-primary disabled:border-border disabled:bg-muted"
>
  <Text>Share usage data</Text>
</Checkbox>

#Accessibility

  • A checkbox is one element for screen readers, with the role "checkbox" (or its role) and its value: checked, unchecked, or mixed while indeterminate. The indicator and the mark 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 checkbox 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 checkbox, 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 checkbox that has the keyboard focus.
  • Every platform draws what the app styles: the indicator, the mark and their transitions.