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.
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"))
}import { mount, signal, Text, View } from "nsh";
import { Radio, RadioGroup } from "nsh/base";
function App() {
const plan = signal("basic");
const option = (value: string, label: string) => (
<Radio
checked={plan() === value}
onChange={() => plan.set(value)}
class="group flex-row items-center gap-2"
>
<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>{label}</Text>
</Radio>
);
return (
<RadioGroup class="gap-3">
{option("basic", "Basic")}
{option("pro", "Pro")}
</RadioGroup>
);
}
mount(App);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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
checked | .checked(..) | boolean | false | Checked or not. A press on an unchecked radio reports true with onChange; the radio shows the value the app passes back. |
disabled | .disabled(..) | boolean | false | No presses and no press feedback; disabled: styles apply, and screen readers say it is dimmed. |
checkedStyle | .checked_style(|s| ..) | StyleProp<ViewStyle> | none | The radio’s style while checked and not disabled, as checked: classes. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers say: the option, when its children have no text that says it. |
children | .child(..) | JSX.Element | none | The app’s indicator and label, which screen readers say. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The radio itself: the row around the indicator and the label. |
activeStyle, hoverStyle, focusStyle, disabledStyle | .active(|s| ..), .hover(..), .focus(..), .disabled_style(..) | StyleProp<ViewStyle> | none | The radio’s style while pressed, hovered, focused or disabled, as the <state>: classes. |
#RadioGroup
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
children | .child(..) | JSX.Element | none | The radios of the set, and whatever the app lays out between them. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The group’s layout and look: a column by default, flex-row for a row. |
#Events
| Event | Rust | Description |
|---|---|---|
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
| Part | Inside | Styled with | States |
|---|---|---|---|
| The group | RadioGroup’s class, style | ||
| The radio | the group | Radio’s class, style | checked:, disabled:, active:, hover:, focus: (or checkedStyle, disabledStyle, …) |
| The indicator and the label | the radio | their 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, oraccessibilityLabel. - A
RadioGrouphas the role"radiogroup": screen readers hear the radios inside it as one set, each still an element of its own. disabledis 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.