Skip to main content
On this pageOverview

Radio Group

Overview

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.

Examples

Vertical

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).

// 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 }),
  })
}

Horizontal

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.

Styling

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.

AttributeCondition
data-checkedPresent on the selected option.
data-activePresent on the option that has focus (roving tabindex).
data-disabledPresent on disabled options.

Keyboard Interaction

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.

KeyDescription
Arrow Down / RightMove focus and select the next option (wraps).
Arrow Up / LeftMove focus and select the previous option (wraps).
HomeMove focus and select the first option.
EndMove focus and select the last option.
SpaceSelect the focused option.

Accessibility

The group element receives role="radiogroup" and aria-orientation. Each option receives role="radio" with aria-checked, aria-labelledby, and aria-describedby.

API Reference

InitConfig

Configuration object passed to RadioGroup.init().

NameTypeDefaultDescription
idstring-Unique ID for the radio group instance.
selectedValuestring-Initially selected option value.
orientation'Vertical' | 'Horizontal''Vertical'Layout orientation. Controls which arrow keys navigate between options.

ViewConfig

Configuration object passed to RadioGroup.view().

NameTypeDefaultDescription
modelRadioGroup.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.
optionsReadonlyArray<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`.
ariaLabelstring-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.
isDisabledbooleanfalseDisables all options.
namestring-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`.

RenderInfo

Payload delivered to the toView callback each render.

NameTypeDefaultDescription
groupReadonlyArray<ChildAttribute>-Spread onto the radio group container. Includes `role="radiogroup"`, `aria-orientation`, and `aria-label`.
optionsReadonlyArray<OptionInfo<Value>>-One entry per option in `viewInputs.options`, in the same order. See OptionInfo below.
selectedValueOption<Value>-The currently-selected value, if any. Convenient when rendering selected-state visuals next to the option attributes.
hiddenInputReadonlyArray<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.

OptionInfo

Each entry in RenderInfo.options. Carries the value, derived state flags, and attribute bundles for the option element, its label, and its description.

NameTypeDefaultDescription
valueValue-The option value. Typed as your `Value` union when the radio group is declared via `Ui.RadioGroup.create<Value>()`.
indexnumber-Position in the `options` array.
isSelectedboolean-Whether this option is currently selected.
isActiveboolean-Whether this option owns the roving tabindex (the one in the tab order).
isDisabledboolean-Whether this option is disabled (either individually via `isOptionDisabled` or because `isDisabled` is set on the whole group).
optionReadonlyArray<ChildAttribute>-Spread onto the option element. Includes `role="radio"`, `aria-checked`, `aria-labelledby`, `aria-describedby`, `tabindex`, and click/keyboard handlers.
labelReadonlyArray<ChildAttribute>-Spread onto the label element. Includes an id for `aria-labelledby`.
descriptionReadonlyArray<ChildAttribute>-Spread onto a description element. Includes an id for `aria-describedby`.

OutMessage

Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.

NameTypeDefaultDescription
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.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson