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

The view function is generic over your option type. Pass a typed options array and an optionToConfig callback that maps each option to a value and a content callback receiving attribute groups.

// 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 } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, div, p, span } from './html'

// Add a field to your Model for the RadioGroup Submodel:
const Model = S.Struct({
  radioGroup: Ui.RadioGroup.Model,
  // ...your other fields
})

// In your init function, initialize the RadioGroup Submodel with a unique id:
const init = () => [
  {
    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 RadioGroup.update:
GotRadioGroupMessage: ({ message }) => {
  const [nextRadioGroup, commands] = Ui.RadioGroup.update(
    model.radioGroup,
    message,
  )

  return [
    // Merge the next state into your Model:
    evo(model, { radioGroup: () => nextRadioGroup }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(
        Effect.map(message => GotRadioGroupMessage({ message })),
      ),
    ),
  ]
}

type Plan = 'Startup' | 'Business' | 'Enterprise'
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, render the radio group:
Ui.RadioGroup.view<Message, Plan>({
  model: model.radioGroup,
  toParentMessage: message => GotRadioGroupMessage({ message }),
  options: plans,
  ariaLabel: 'Server plan',
  optionToConfig: (plan, { isSelected }) => ({
    value: plan,
    content: attributes =>
      div(
        [
          ...attributes.option,
          Class(
            'rounded-lg border p-4 cursor-pointer data-[checked]:border-blue-600',
          ),
        ],
        [
          span([...attributes.label, Class('text-sm font-medium')], [plan]),
          p(
            [...attributes.description, Class('text-sm text-gray-500')],
            [descriptions[plan]],
          ),
        ],
      ),
  }),
  attributes: [Class('flex flex-col gap-3')],
})

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 optionToConfig callback controls all option markup and styling. 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<RadioOption>-The list of options. The generic RadioOption type narrows the value passed to optionToConfig.
optionToConfig(option, context) => OptionConfig-Maps each option to its value and content callback. The context provides isSelected, isActive, and isDisabled.
ariaLabelstring-Accessible label for the radio group.
orientation'Vertical' | 'Horizontal'-Overrides the orientation set at init. Controls arrow key direction and aria-orientation.
isDisabledbooleanfalseDisables all options.
isOptionDisabled(option, index) => boolean-Disables individual options.
onSelected(value, index) => Message-Alternative to Submodel delegation — fires your own Message on selection instead of the internal SelectedOption. Use with RadioGroup.select() to update the Model.
namestring-Form field name. When provided, a hidden input is included with the selected value.
attributesReadonlyArray<Attribute<Message>>-Additional attributes for the radio group container.
classNamestring-CSS class for the radio group container.

OptionAttributes

Attribute groups provided to each option’s content callback.

NameTypeDefaultDescription
optionReadonlyArray<Attribute<Message>>-Spread onto the radio option element. Includes role, aria-checked, tabindex, and click/keyboard handlers.
labelReadonlyArray<Attribute<Message>>-Spread onto the label element. Includes an id for aria-labelledby.
descriptionReadonlyArray<Attribute<Message>>-Spread onto a description element. Includes an id for aria-describedby.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson