Elements

Radio

Radios offer a set of options the user picks exactly one of: a plan, a sort order, a theme. Each Radio owns the behavior of one option: presses, keyboard activation, the disabled state, and the radio role and its checked state for screen readers. A RadioGroup around them is the set, which screen readers announce as one group. The look is the app’s: a radio has no circle, dot, colors, sizes or label of its own. Its children are the app’s indicator and label, and checked: classes on the radio, or group-checked: classes on what is inside it, follow its state.

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

const plans = [
  { id: "starter", label: "Starter", detail: "1 project · 5 GB", price: "Free" },
  { id: "pro", label: "Pro", detail: "Unlimited projects · 100 GB", price: "$12" },
  { id: "team", label: "Team", detail: "Shared workspace · 1 TB", price: "$40", locked: true },
];
const cycles = ["Monthly", "Yearly"];

function App() {
  const plan = signal("pro");
  const cycle = signal("Monthly");
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 3));
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>radio</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click an option, or Tab to one and press Space" : "Tap an option: its label checks it too"}
      </Text>

      <Split gap={14} inspector={<Log title={`onChange(checked) · ${plan()}, ${cycle()}`} lines={events()} minHeight={76} />}>
        {/* A segmented choice: each radio is a segment, `checkedStyle` raises the checked one. */}
        <RadioGroup style={{ flexDirection: "row", gap: 4, padding: 3, borderRadius: 10, ...ui().inset, ...(desktop ? { alignSelf: "flex-start" } : {}) }}>
          <Each of={cycles}>
            {(c) => (
              <Radio
                checked={cycle() === c()}
                onChange={(next) => {
                  cycle.set(c());
                  log(`${c()} → ${next}`);
                }}
                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={cycle() === c() ? undefined : hoverFill()}
                focusStyle={focusRing()}
                checkedStyle={ui().raised}
              >
                <Text style={{ fontSize: 13, fontWeight: "500", color: cycle() === c() ? ui().text : ui().muted }}>{c()}</Text>
              </Radio>
            )}
          </Each>
        </RadioGroup>

        {/* A list of options: a ring with a dot, which `group-checked:` shows. */}
        <RadioGroup style={{ ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <Each of={plans}>
            {(item, i) => (
              <>
                {i() > 0 ? <View style={{ height: 1, marginLeft: desktop ? 40 : 48, backgroundColor: ui().line }} /> : null}
                <Radio
                  checked={plan() === item().id}
                  disabled={item().locked}
                  onChange={(next) => {
                    plan.set(item().id);
                    log(`${item().label} → ${next}`);
                  }}
                  class={`group flex-row items-center gap-3 ${desktop ? "px-3 py-2" : "px-4 py-3"} active:opacity-70 disabled:opacity-40`}
                  hoverStyle={item().locked ? undefined : hoverFill()}
                  focusStyle={focusRing()}
                >
                  <View
                    style={{
                      width: desktop ? 16 : 20,
                      height: desktop ? 16 : 20,
                      borderRadius: desktop ? 8 : 10,
                      borderWidth: 2,
                      borderColor: plan() === item().id ? ui().accent : ui().faint,
                      alignItems: "center",
                      justifyContent: "center",
                    }}
                  >
                    <View
                      class="opacity-0 group-checked:opacity-100"
                      style={{ width: desktop ? 8 : 10, height: desktop ? 8 : 10, borderRadius: desktop ? 4 : 5, backgroundColor: ui().accent }}
                    />
                  </View>
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{item().label}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{item().detail}</Text>
                  </View>
                  <Text style={{ fontSize: 14, fontWeight: "600", fontVariant: ["tabular-nums"], color: ui().text }}>{item().price}</Text>
                </Radio>
              </>
            )}
          </Each>
        </RadioGroup>
      </Split>
    </View>
  );
}

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

fn plans() -> impl IntoElement {
    let plan = signal("basic");
    let option = move |value: &'static str, label: &'static str| {
        let checked = move || plan.get() == value;
        radio()
            .checked(checked)
            .on_change(move |_: &bool| plan.set(value))
            .flex_row()
            .items_center()
            .gap_2()
            .child(
                view()
                    .size(px(20.0))
                    .items_center()
                    .justify_center()
                    .rounded_full()
                    .border_2()
                    .border_color(move || theme().border)
                    .child(
                        view()
                            .size(px(10.0))
                            .rounded_full()
                            .bg(move || theme().primary)
                            .opacity(move || if checked() { 1.0_f32 } else { 0.0 }),
                    ),
            )
            .child(text(label))
    };
    radio_group()
        .gap_3()
        .child(option("basic", "Basic"))
        .child(option("pro", "Pro"))
}

Radio and RadioGroup come from nsh/base, the nsh-base crate in Rust. The value is the app’s: the group holds none. Each radio is told whether it is checked; a press on an unchecked radio reports onChange(true), the app sets its value, and every radio shows the checked the app passes back. In Rust, radio() and radio_group() make them, with a setter per prop (.checked(..)), a listener per event (.on_change(..)), the typed style methods and .child(..) for their content.

#Props

#Radio

PropRustTypeDefaultDescription
checked.checked(..)booleanfalseChecked or not. A press on an unchecked radio reports true with onChange; the radio 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.
checkedStyle.checked_style(|s| ..)StyleProp<ViewStyle>noneThe radio’s style while checked and not disabled, as checked: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say: the option, when its children have no text that says it.
children.child(..)JSX.ElementnoneThe app’s indicator and label, which screen readers say.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe radio itself: the row around the indicator and the label.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe radio’s style while pressed, hovered, focused or disabled, as the <state>: classes.

#RadioGroup

PropRustTypeDefaultDescription
children.child(..)JSX.ElementnoneThe radios of the set, and whatever the app lays out between them.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe group’s layout and look: a column by default, flex-row for a row.

#Events

EventRustDescription
onChange (Radio).on_change(|checked: &bool| ..)An unchecked radio was pressed: a tap, a click, Space or Return while it has the keyboard focus, or a screen reader’s activation. It asks to be checked, so the value is true. It runs before anything changes: the radio shows the checked the app passes back.

A checked radio stays checked and reports nothing, as a radio cannot uncheck itself; a disabled radio reports nothing either.

#Parts

PartInsideStyled withStates
The groupRadioGroup’s class, style
The radiothe groupRadio’s class, stylechecked:, disabled:, active:, hover:, focus: (or checkedStyle, disabledStyle, …)
The indicator and the labelthe radiotheir own elements (children)group-checked:, group-disabled: and the other group-*: variants with group on the radio’s class

The indicator is the app’s content: a ring with a dot, a check, a highlighted card. With group on the radio’s class, its group-checked: classes show the dot while the radio is checked.

#Disabled

A disabled radio takes no presses, and screen readers announce it as dimmed. Its disabled: classes apply over its checked: ones, and group-disabled: classes inside it follow:

<Radio
  checked={plan() === "team"}
  disabled={!eligible()}
  onChange={() => plan.set("team")}
  class="group flex-row items-center gap-2 disabled:opacity-50"
>
  <View class="size-5 items-center justify-center rounded-full border-2 border-border group-checked:border-primary">
    <View class="size-2.5 rounded-full group-checked:bg-primary" />
  </View>
  <Text>Team</Text>
</Radio>

#Accessibility

  • A radio is one element for screen readers, with the role "radio" and its state: checked or not, and selected while checked, since some screen readers read the one and some the other (UI Automation’s radios are selection items). Its indicator and label are parts of it; its label is the text of its children, or accessibilityLabel.
  • A RadioGroup has the role "radiogroup": screen readers hear the radios inside it as one set, 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 each radio is a stop of the keyboard focus: Tab reaches it, Space and Return check 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 radio, 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 check the radio that has the keyboard focus.
  • Every platform draws what the app styles: the indicator, the label and their transitions.