On this pageOverview
Radio Group
A single-selection component with roving tabindex keyboard navigation. Arrow keys simultaneously move focus and select the option. There is no separate focus-then-select step. RadioGroup uses the Submodel pattern and supports both vertical and horizontal orientation.
See it in an app
Check out how RadioGroup is wired up in a real Foldkit app.
Declare the radio group once at module scope with Ui.RadioGroup.create<Value>() to lift the option type through view, update, and select without casting. Pass the typed options array and a toView callback that receives one OptionInfo<Value> per option (with attribute bundles for the option, label, and description).
12GB / 6 CPUs. Perfect for small projects
16GB / 8 CPUs. For growing teams
32GB / 12 CPUs. Dedicated infrastructure
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
type Plan = 'Startup' | 'Business' | 'Enterprise'
// Declare a typed RadioGroup once at module scope. `view` and `update`
// are bound to the same value type:
const PlanRadioGroup = Ui.RadioGroup.create<Plan>()
// Add a field to your Model for the RadioGroup Submodel, plus a field
// for the selected plan your app actually cares about:
const Model = S.Struct({
maybePlan: S.Option(S.String),
radioGroup: Ui.RadioGroup.Model,
// ...your other fields
})
// In your init function, initialize the RadioGroup Submodel with a unique id:
const init = () => [
{
maybePlan: Option.none(),
radioGroup: Ui.RadioGroup.init({ id: 'plan' }),
// ...your other fields
},
[],
]
// Embed the RadioGroup Message in your parent Message:
const GotRadioGroupMessage = m('GotRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
// Inside your update function's M.tagsExhaustive({...}), delegate to
// PlanRadioGroup.update. The OutMessage's `Selected` carries the chosen
// value typed as `Plan` (the type param at the factory):
GotRadioGroupMessage: ({ message }) => {
const [nextRadioGroup, commands, maybeOutMessage] = PlanRadioGroup.update(
model.radioGroup,
message,
)
const mappedCommands = Command.mapMessages(commands, message =>
GotRadioGroupMessage({ message }),
)
return Option.match(maybeOutMessage, {
onNone: () => [
evo(model, { radioGroup: () => nextRadioGroup }),
mappedCommands,
],
onSome: M.type<Ui.RadioGroup.OutMessage<Plan>>().pipe(
M.tagsExhaustive({
Selected: ({ value }) => [
evo(model, {
radioGroup: () => nextRadioGroup,
maybePlan: () => Option.some(value),
}),
mappedCommands,
],
}),
),
})
}
const plans: ReadonlyArray<Plan> = ['Startup', 'Business', 'Enterprise']
const descriptions: Record<Plan, string> = {
Startup: '12GB / 6 CPUs. Perfect for small projects',
Business: '16GB / 8 CPUs. For growing teams',
Enterprise: '32GB / 12 CPUs. Dedicated infrastructure',
}
// Inside your view function, embed the radio group via h.submodel:
const view = () => {
const h = html<Message>()
return h.submodel({
slotId: 'plan',
model: model.radioGroup,
view: PlanRadioGroup.view,
viewInputs: {
options: plans,
ariaLabel: 'Server plan',
toView: ({ group, options }) =>
h.div(
[...group, h.Class('flex flex-col gap-3')],
options.map(option => {
const plan = option.value
return h.div(
[
...option.option,
h.Class(
'rounded-lg border p-4 cursor-pointer data-[checked]:border-blue-600',
),
],
[
h.span(
[...option.label, h.Class('text-sm font-medium')],
[plan],
),
h.p(
[...option.description, h.Class('text-sm text-gray-500')],
[descriptions[plan]],
),
],
)
}),
),
},
toParentMessage: message => GotRadioGroupMessage({ message }),
})
}Pass orientation: 'Horizontal' to switch to left/right arrow navigation. Set the orientation at init time or override it per render in the view config.
12GB / 6 CPUs. Perfect for small projects
16GB / 8 CPUs. For growing teams
32GB / 12 CPUs. Dedicated infrastructure
RadioGroup is headless. The toView callback owns all option markup and styling, spreading the attribute bundles from each OptionInfo onto the consumer's elements. Use the data attributes below to style selected, focused, and disabled states.
| Attribute | Condition |
|---|---|
data-checked | Present on the selected option. |
data-active | Present on the option that has focus (roving tabindex). |
data-disabled | Present on disabled options. |
RadioGroup uses roving tabindex: only the active option is in the tab order. Arrow keys move focus and select simultaneously. Disabled options are skipped during keyboard navigation.
| Key | Description |
|---|---|
| Arrow Down / Right | Move focus and select the next option (wraps). |
| Arrow Up / Left | Move focus and select the previous option (wraps). |
| Home | Move focus and select the first option. |
| End | Move focus and select the last option. |
| Space | Select the focused option. |
The group element receives role="radiogroup" and aria-orientation. Each option receives role="radio" with aria-checked, aria-labelledby, and aria-describedby.
Configuration object passed to RadioGroup.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the radio group instance. |
selectedValue | string | - | Initially selected option value. |
orientation | 'Vertical' | 'Horizontal' | 'Vertical' | Layout orientation. Controls which arrow keys navigate between options. |
Configuration object passed to RadioGroup.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | RadioGroup.Model | - | The radio group state from your parent Model. |
toParentMessage | (childMessage: RadioGroup.Message) => ParentMessage | - | Wraps RadioGroup Messages in your parent Message type for Submodel delegation. |
options | ReadonlyArray<Value> | - | The list of option values, in display order. When the radio group is declared via `Ui.RadioGroup.create<MyUnion>()`, `Value` is your union type and each `OptionInfo.value` is typed as `MyUnion`. |
ariaLabel | string | - | Accessible label for the radio group. |
toView | (render: RenderInfo<Value>) => Html | - | Callback that receives the `group` attribute bundle, one `OptionInfo<Value>` per option, the current `selectedValue`, and the `hiddenInput` attributes. Returns the composed layout. |
isOptionDisabled | (value: Value, index: number) => boolean | - | Disables individual options. |
isDisabled | boolean | false | Disables all options. |
name | string | - | Form field name. When provided, `RenderInfo.hiddenInput` carries the attributes for a hidden `<input>` holding the selected value (the consumer renders the element). |
orientation | 'Vertical' | 'Horizontal' | - | Overrides the orientation set at init. Controls arrow key direction and `aria-orientation`. |
Payload delivered to the toView callback each render.
| Name | Type | Default | Description |
|---|---|---|---|
group | ReadonlyArray<ChildAttribute> | - | Spread onto the radio group container. Includes `role="radiogroup"`, `aria-orientation`, and `aria-label`. |
options | ReadonlyArray<OptionInfo<Value>> | - | One entry per option in `viewInputs.options`, in the same order. See OptionInfo below. |
selectedValue | Option<Value> | - | The currently-selected value, if any. Convenient when rendering selected-state visuals next to the option attributes. |
hiddenInput | ReadonlyArray<ChildAttribute> | - | When `viewInputs.name` is supplied, attributes for a hidden form input carrying the selected value. The consumer renders the `<input>` element. Empty array when `name` is undefined. |
Each entry in RenderInfo.options. Carries the value, derived state flags, and attribute bundles for the option element, its label, and its description.
| Name | Type | Default | Description |
|---|---|---|---|
value | Value | - | The option value. Typed as your `Value` union when the radio group is declared via `Ui.RadioGroup.create<Value>()`. |
index | number | - | Position in the `options` array. |
isSelected | boolean | - | Whether this option is currently selected. |
isActive | boolean | - | Whether this option owns the roving tabindex (the one in the tab order). |
isDisabled | boolean | - | Whether this option is disabled (either individually via `isOptionDisabled` or because `isDisabled` is set on the whole group). |
option | ReadonlyArray<ChildAttribute> | - | Spread onto the option element. Includes `role="radio"`, `aria-checked`, `aria-labelledby`, `aria-describedby`, `tabindex`, and click/keyboard handlers. |
label | ReadonlyArray<ChildAttribute> | - | Spread onto the label element. Includes an id for `aria-labelledby`. |
description | ReadonlyArray<ChildAttribute> | - | Spread onto a description element. Includes an id for `aria-describedby`. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.
| Name | Type | Default | Description |
|---|---|---|---|
Selected | { value: Value; index: number } | - | Emitted when an option is committed via click or keyboard. Pattern-match the third tuple element of RadioGroup.update in your GotRadioGroupMessage handler to lift the value into domain state. Programmatic `RadioGroup.select(model, value, options)` carries the same signal. |