Styled radio options with roving tabindex, built on the @foldkit/ui RadioGroup submodel.

Preview
Style
Basic
With Descriptions
With FieldSet
Battery Level

Choose your preferred battery level.

Default
Comfortable
Compact
Disabled
Invalid
Notification Preferences

Choose how you want to receive notifications.

src/demo/views/radio-group.ts225 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { RadioGroup as FoldkitRadioGroup } from '@foldkit/ui'

import * as radioGroup from '../../generated/registry/ui/radio-group'
import {
  field,
  fieldDescription,
  fieldLabel,
  fieldLegend,
  fieldSet,
} from '../../generated/registry/ui/fieldset'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotRadioGroupMessage: { message: radioGroup.Message },
})

const RadioValue = S.Literals(['default', 'comfortable', 'compact'])
type RadioValue = typeof RadioValue.Type

const RadioDemoGroup = radioGroup.create<RadioValue>()

export const radioGroupView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.radioGroup.id,
            model: model.radioGroup,
            view: RadioDemoGroup.view,
            viewInputs: radioGroup.styledViewInputs<AppMessage, RadioValue>(
              {
                options: ['default', 'comfortable', 'compact'],
                selectedValue: model.maybeRadioValue,
                ariaLabel: 'Density',
                optionLabel: (value) => value.charAt(0).toUpperCase() + value.slice(1),
                groupClass: 'w-fit',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotRadioGroupMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Descriptions']),
          h.div(
            [h.Class('flex flex-col gap-1')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>(
                    { for: 'plus-plan' },
                    ['Plus — For individuals and small teams'],
                    h,
                  ),
                  fieldLabel<AppMessage>({ for: 'pro-plan' }, ['Pro — For growing businesses'], h),
                  fieldLabel<AppMessage>(
                    { for: 'enterprise-plan' },
                    ['Enterprise — For large teams'],
                    h,
                  ),
                ],
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With FieldSet']),
          fieldSet<AppMessage>(
            {},
            [
              fieldLegend<AppMessage>({}, ['Battery Level'], h),
              fieldDescription<AppMessage>({}, ['Choose your preferred battery level.'], h),
              h.div(
                [h.Class('flex flex-col gap-2 text-sm')],
                [
                  h.div(
                    [h.Class('flex items-center gap-2')],
                    [
                      h.input([
                        h.Type('radio'),
                        h.Attribute('name', 'battery-demo'),
                        h.Class('size-4'),
                      ]),
                      h.span([], ['Default']),
                    ],
                  ),
                  h.div(
                    [h.Class('flex items-center gap-2')],
                    [
                      h.input([
                        h.Type('radio'),
                        h.Attribute('name', 'battery-demo'),
                        h.Class('size-4'),
                        h.Attribute('checked', ''),
                      ]),
                      h.span([], ['Comfortable']),
                    ],
                  ),
                  h.div(
                    [h.Class('flex items-center gap-2')],
                    [
                      h.input([
                        h.Type('radio'),
                        h.Attribute('name', 'battery-demo'),
                        h.Class('size-4'),
                      ]),
                      h.span([], ['Compact']),
                    ],
                  ),
                ],
              ),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Disabled']),
          h.div(
            [h.Class('opacity-60')],
            [
              field<AppMessage>(
                { orientation: 'horizontal' },
                [fieldLabel<AppMessage>({ for: 'disabled-1' }, ['Option 1'], h)],
                h,
              ),
              field<AppMessage>(
                { orientation: 'horizontal' },
                [fieldLabel<AppMessage>({ for: 'disabled-2' }, ['Option 2'], h)],
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Invalid']),
          fieldSet<AppMessage>(
            {},
            [
              fieldLegend<AppMessage>({}, ['Notification Preferences'], h),
              fieldDescription<AppMessage>(
                {},
                ['Choose how you want to receive notifications.'],
                h,
              ),
              field<AppMessage>(
                { orientation: 'horizontal', isInvalid: true },
                [fieldLabel<AppMessage>({ for: 'invalid-email' }, ['Email only'], h)],
                h,
              ),
              field<AppMessage>(
                { orientation: 'horizontal', isInvalid: true },
                [fieldLabel<AppMessage>({ for: 'invalid-sms' }, ['SMS only'], h)],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

const foldRadioGroupOutMessage = M.type<FoldkitRadioGroup.OutMessage<RadioValue>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({ model: evo(model, { maybeRadioValue: () => Option.some(value) }) }),
  }),
)

const foldRadioGroup = Update.foldChild({
  update: RadioDemoGroup.update,
  read: (model: State) => Option.some(model.radioGroup),
  write: (model, next) => evo(model, { radioGroup: () => next }),
  toParentMessage: (message) => Message.GotRadioGroupMessage({ message }),
  foldOutMessage: foldRadioGroupOutMessage,
})

const fields = { radioGroup: radioGroup.Model, maybeRadioValue: S.Option(RadioValue) }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    radioGroup: radioGroup.init({ id: 'radio-group-demo' }),
    maybeRadioValue: Option.some('comfortable' satisfies RadioValue),
  },
  messages: [Message.GotRadioGroupMessage],
  handlers: (model: State) => ({
    GotRadioGroupMessage: (payload: typeof Message.GotRadioGroupMessage.Type): UpdateReturn =>
      foldRadioGroup(model, payload.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/radio-group

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/radio-group.ts205 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as RadioGroup from '@/components/ui/radio-group'`
 */
import { RadioGroup as FoldkitRadioGroup } from '@foldkit/ui'
import type { Option } from 'effect/Option'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'

/**
 * Two rendering paths in `styledViewInputs`:
 *  - `optionLabel` (+ optional `optionDescription`) → upstream anatomy: a
 *    16px circle control carrying the foldkit attributes, an indicator dot
 *    mounted only while selected, and a wired label/description pair.
 *  - legacy `option` callback → consumer-owned row content inside a
 *    group-attributed wrapper (kept for backward compatibility).
 *
 */

// Re-export the @foldkit/ui RadioGroup surface. Create a bundle once per
// option type:
//
//   export const PlanRadioGroup = RadioGroup.create<"Startup" | "Business">()

export const create = FoldkitRadioGroup.create
export const init = FoldkitRadioGroup.init
export const Model = FoldkitRadioGroup.Model
export type Model = typeof Model.Type
export const Message = FoldkitRadioGroup.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitRadioGroup.OutMessage
export type OutMessage = typeof OutMessage.Type

export type Bundle<Value extends string = string> = FoldkitRadioGroup.Bundle<Value>
export type InitConfig = FoldkitRadioGroup.InitConfig
export type ViewInputs<Value extends string = string> = FoldkitRadioGroup.ViewInputs<Value>
export type RenderInfo<Value extends string = string> = FoldkitRadioGroup.RenderInfo<Value>

/** Upstream group component string. Horizontal orientation appends a
 *  responsive row extension (foldcn API exposes orientation; upstream leaves
 *  layout to the consumer). */
export const radioGroupClass = 'grid gap-2 w-full'

export const radioGroupVerticalClass = 'grid gap-2 w-full'

export const radioGroupHorizontalClass = 'grid gap-2 w-full sm:flex-row sm:items-center'

/** Upstream item component string. The disabled: variants are inert under
 *  foldkit (aria-/data- twins are inlined at style resolution). */
export const radioItemClass =
  'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50'

export const radioIndicatorClass = 'flex size-4 items-center justify-center'

export const radioDotClass = 'bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full'

/** Label for the upstream-anatomy option row. The circle control (radioItemClass)
 *  carries `peer` and precedes the label, and foldkit emits aria-disabled/
 *  data-disabled on it when disabled — so the peer twins below are live
 *  (upstream's native peer-disabled variant can never match). */
export const radioItemLabelClass =
  'gap-2 text-sm leading-none font-medium group-data-[disabled]:opacity-50 flex items-center select-none peer-aria-disabled:pointer-events-none peer-aria-disabled:cursor-not-allowed peer-data-[disabled]:pointer-events-none peer-data-[disabled]:cursor-not-allowed'

export const radioItemDescriptionClass = 'text-sm text-muted-foreground'

/** Legacy consumer-drawn row (used when `option` callback is supplied). */
export const radioOptionClass =
  'group/radio-group-item peer relative flex cursor-pointer select-none items-center justify-between rounded-md border border-border bg-card p-4 text-card-foreground shadow-sm transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 hover:bg-accent hover:text-accent-foreground data-[checked]:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50'

/** Legacy path label: nested inside the option row (which itself is the
 *  `group/radio-group-item` carrying foldkit's data-disabled), so disabled
 *  state flows via the group variant — a `peer-*` form can never match a
 *  descendant of the peer element. */
export const radioOptionLabelClass =
  'text-sm font-medium leading-none group-data-[disabled]/radio-group-item:cursor-not-allowed group-data-[disabled]/radio-group-item:opacity-70'

export const radioOptionDescriptionClass = 'text-sm text-muted-foreground'

export type StyledViewInputs<M, Value extends string = string> = Readonly<{
  options: ReadonlyArray<Value>
  selectedValue: Option<Value>
  ariaLabel: string
  /** Legacy path: renders each option row's content yourself. Receives the
   *  option value, its per-option render info (`isSelected`, attribute
   *  bundles) and the full render. Mutually exclusive with `optionLabel`. */
  option?: (
    value: Value,
    info: FoldkitRadioGroup.OptionInfo<Value>,
    render: RenderInfo<Value>,
    h: HtmlBuilder<M>,
  ) => Html
  /** Upstream-anatomy path: plain text label per option, rendered next to a
   *  circle control with the indicator dot. */
  optionLabel?: (value: Value) => string
  optionDescription?: (value: Value) => string
  orientation?: 'Horizontal' | 'Vertical'
  isOptionDisabled?: (value: Value, index: number) => boolean
  isDisabled?: boolean
  isReadOnly?: boolean
  name?: string
  groupClass?: string
  optionClass?: string
}>

const defaultOptionRow = <M, Value extends string>(
  info: FoldkitRadioGroup.OptionInfo<Value>,
  labelText: string,
  descriptionText: string | undefined,
  optionClass: string | undefined,
  h: HtmlBuilder<M>,
): Html =>
  h.label(
    [h.Class(cn('flex w-full items-center gap-2', optionClass))],
    [
      h.button(
        [...info.option, h.DataAttribute('slot', 'radio-group-item'), h.Class(radioItemClass)],
        info.isSelected
          ? [
              h.span(
                [h.DataAttribute('slot', 'radio-group-indicator'), h.Class(radioIndicatorClass)],
                [h.span([h.Class(radioDotClass)])],
              ),
            ]
          : [],
      ),
      h.span(
        [
          ...info.label,
          h.DataAttribute('slot', 'radio-group-item-label'),
          h.Class(radioItemLabelClass),
        ],
        [labelText],
      ),
      ...(descriptionText === undefined
        ? []
        : [
            h.span(
              [
                ...info.description,
                h.DataAttribute('slot', 'radio-group-item-description'),
                h.Class(radioItemDescriptionClass),
              ],
              [descriptionText],
            ),
          ]),
    ],
  )

/** Build styled `RadioGroup.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M, Value extends string = string>(
  viewInputs: StyledViewInputs<M, Value>,
  h: HtmlBuilder<M>,
): ViewInputs<Value> => {
  const isHorizontal = viewInputs.orientation === 'Horizontal'
  return {
    options: viewInputs.options,
    selectedValue: viewInputs.selectedValue,
    ariaLabel: viewInputs.ariaLabel,
    orientation: viewInputs.orientation,
    isOptionDisabled: viewInputs.isOptionDisabled,
    isDisabled: viewInputs.isDisabled,
    isReadOnly: viewInputs.isReadOnly,
    name: viewInputs.name,
    toView: (render) => {
      const { group, options, hiddenInput } = render
      return h.div(
        [
          ...group,
          h.DataAttribute('slot', 'radio-group'),
          h.Class(
            cn(
              isHorizontal ? radioGroupHorizontalClass : radioGroupVerticalClass,
              viewInputs.groupClass,
            ),
          ),
        ],
        [
          ...options.map((option) => {
            if (viewInputs.option !== undefined) {
              // Legacy path: consumer-owned content inside the attributed row.
              return h.div(
                [
                  ...option.option,
                  h.DataAttribute('slot', 'radio-group-item'),
                  h.Class(cn(radioOptionClass, viewInputs.optionClass)),
                ],
                [viewInputs.option(option.value, option, render, h)],
              )
            }
            return defaultOptionRow(
              option,
              viewInputs.optionLabel?.(option.value) ?? String(option.value),
              viewInputs.optionDescription?.(option.value),
              viewInputs.optionClass,
              h,
            )
          }),
          ...(hiddenInput.length > 0 ? [h.input([...hiddenInput])] : []),
        ],
      )
    },
  }
}