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 a group and move with the arrow keys" : "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={({ checked }) => {
                  cycle.set(c());
                  log(`${c()} → ${checked}`);
                }}
                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:` grows in (in place with reduced motion). */}
        <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={({ checked }) => {
                    plan.set(item().id);
                    log(`${item().label} → ${checked}`);
                  }}
                  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
                    class="transition-colors motion-reduce:transition-none"
                    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 scale-50 transition duration-150 ease-out motion-reduce:transition-none group-checked:opacity-100 group-checked:scale-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::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({ checked: 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.
hitSlop.hit_slop(..)number | Partial<Edges>noneHow far outside its bounds presses still count, in points: one number for every side, or the sides named. As a view’s hitSlop.
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; or an arrow key of its group moved the keyboard focus to it. It asks to be checked, so checked 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>

#A small target with hitSlop

A radio with no label beside it (a dot in a table’s row) can be smaller than a finger. hitSlop makes presses that land near it count, in points past each side, without changing its layout or its look: one number for every side, or the sides named ({ top: 8, bottom: 8 }), as a view’s hitSlop takes them.

<Radio
  checked={plan() === "team"}
  hitSlop={12}
  accessibilityLabel="Team"
  onChange={() => plan.set("team")}
  class="group 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" />
</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 a RadioGroup is one stop of the keyboard focus, and the arrow keys move between its radios and check them (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 RadioGroup follows the WAI-ARIA radio group pattern:

KeyWhat it does
Tab, Shift+TabEnters the group at the checked radio (the first radio that is not disabled while none is), and leaves it in one step.
Down, RightMoves the focus to the next radio, from the last to the first, and checks it.
Up, LeftMoves the focus to the previous radio, from the first to the last, and checks it.
SpaceChecks the focused radio.

The arrow keys skip disabled radios; the radio they move to asks to be checked with onChange, and the app checks it. In a right-to-left layout Left and Right trade places: Left moves to the next radio and Right to the previous; Up and Down keep theirs. focus: styles show where the focus is. A Radio outside a RadioGroup is a stop of its own, which Tab reaches and Space and Return check.

#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, and the arrow keys move it (Keyboard).
  • Every platform draws what the app styles: the indicator, the label and their transitions.