# Radio Group

A set of radio buttons where only one option can be selected at a time.

Radio Group is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/radio-group

**React**

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "email", label: "Email" },
  { value: "sms", label: "Text message" },
  { value: "push", label: "Push notification" },
]

export default function RadioGroupDemo() {
  return (
    <div className="w-64">
      <RadioGroup aria-label="Notification method" defaultValue="email">
        {options.map((option) => (
          <Label key={option.value}>
            <RadioGroupItem value={option.value} />
            {option.label}
          </Label>
        ))}
      </RadioGroup>
    </div>
  )
}
```

Radio Group wraps the radio group primitive of the chosen base. `RadioGroup` holds the selected value and contains one `RadioGroupItem` per option. Each item renders a round control with an indicator dot when checked; pair it with a `Label` to give it visible text.

Use a radio group for a small set of mutually exclusive options that should all stay visible. Use Select or Native Select for longer lists, and Checkbox when several options can be selected.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/radio-group
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add radio-group
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react
```

2. Copy the source into `components/ui/radio-group.tsx`:

```tsx title="components/ui/radio-group.tsx"
"use client"

import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"

function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
  return (
    <RadioGroupPrimitive
      data-slot="radio-group"
      className={cn("grid w-full gap-3", className)}
      {...props}
    />
  )
}

function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
  return (
    <RadioPrimitive.Root
      data-slot="radio-group-item"
      className={cn(
        "peer relative flex aspect-square size-4 shrink-0 rounded-full border border-foreground/25 bg-background shadow-[0_0.5px_1px_color-mix(in_oklab,var(--foreground)_12%,transparent)] outline-none group-has-focus-visible/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:not-data-checked:border-transparent dark:not-data-checked:bg-border dark:shadow-none data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground data-checked:shadow-none data-disabled:cursor-not-allowed data-disabled:shadow-none data-disabled:data-checked:border-transparent aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      <RadioPrimitive.Indicator
        data-slot="radio-group-indicator"
        className="flex size-4 items-center justify-center"
      >
        <span className="absolute top-1/2 inset-s-1/2 size-2 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground dark:size-2.5" />
      </RadioPrimitive.Indicator>
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, RadioGroupItem }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
```

```tsx
<div className="w-64">
  <RadioGroup aria-label="Notification method" defaultValue="email">
    {options.map((option) => (
      <Label key={option.value}>
        <RadioGroupItem value={option.value} />
        {option.label}
      </Label>
    ))}
  </RadioGroup>
</div>
```

Give each `RadioGroupItem` a unique `value`. The group is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`. Set `disabled` on the group to disable every item, or on a single item.

Wrap each item and its text in a `Label` so clicking the text selects the option. The label switches to regular weight automatically next to a radio item.

## Examples

### With descriptions

Options with a title and a secondary description, linked with `aria-labelledby` and `aria-describedby`.

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export default function RadioGroupWithDescriptions() {
  return (
    <div className="w-64">
      <RadioGroup aria-label="Shipping speed" defaultValue="standard">
        {[
          {
            value: "standard",
            label: "Standard",
            description: "Arrives in 4 to 6 business days.",
          },
          {
            value: "express",
            label: "Express",
            description: "Arrives in 1 to 2 business days.",
          },
        ].map((option) => (
          <Label key={option.value} className="items-start">
            <RadioGroupItem
              value={option.value}
              aria-labelledby={`${option.value}-label`}
              aria-describedby={`${option.value}-description`}
            />
            <span className="flex flex-col gap-1">
              <span id={`${option.value}-label`}>{option.label}</span>
              <span
                id={`${option.value}-description`}
                className="text-muted-foreground font-normal"
              >
                {option.description}
              </span>
            </span>
          </Label>
        ))}
      </RadioGroup>
    </div>
  )
}
```

### Disabled item

A group where one option is disabled and skipped by arrow-key navigation.

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "email", label: "Email" },
  { value: "sms", label: "Text message" },
  { value: "push", label: "Push notification" },
]

export default function RadioGroupDisabledItem() {
  return (
    <div className="w-64">
      <RadioGroup aria-label="Notification method" defaultValue="email">
        {options.map((option) => (
          <Label key={option.value}>
            <RadioGroupItem
              value={option.value}
              disabled={option.value === "push"}
            />
            {option.label}
          </Label>
        ))}
      </RadioGroup>
    </div>
  )
}
```

### Disabled

A fully disabled group that ignores clicks.

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "email", label: "Email" },
  { value: "sms", label: "Text message" },
  { value: "push", label: "Push notification" },
]

export default function RadioGroupDisabled() {
  return (
    <div className="w-64">
      <RadioGroup
        aria-label="Notification method"
        defaultValue="email"
        disabled
      >
        {options.map((option) => (
          <Label key={option.value}>
            <RadioGroupItem value={option.value} />
            {option.label}
          </Label>
        ))}
      </RadioGroup>
    </div>
  )
}
```

### Invalid

Items marked with `aria-invalid` and shown with a destructive border.

**Example**

```tsx
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "email", label: "Email" },
  { value: "sms", label: "Text message" },
  { value: "push", label: "Push notification" },
]

export default function RadioGroupInvalid() {
  return (
    <div className="w-64">
      <RadioGroup aria-label="Notification method" defaultValue={undefined}>
        {options.map((option) => (
          <Label key={option.value}>
            <RadioGroupItem value={option.value} aria-invalid />
            {option.label}
          </Label>
        ))}
      </RadioGroup>
    </div>
  )
}
```

## API Reference

- `RadioGroup`
- `RadioGroupItem`

### RadioGroup

The container that manages the selected value and keyboard navigation.

Also accepts the props of the primitive part it wraps.

### RadioGroupItem

A single radio button with a checked indicator.

Also accepts the props of the primitive part it wraps.

## Accessibility

- The group has the `radiogroup` role and each item the `radio` role with `aria-checked`.
- Name the group with `aria-label` or `aria-labelledby`, and name each item with a wrapping `Label` or `aria-labelledby`.
- Arrow keys move focus and selection between items, skipping disabled ones.
- Set `aria-invalid` on items to communicate a validation error.
- Each item has an enlarged invisible hit area for easier pointer targeting.

## Notes

- Styling hooks: `data-slot` values `radio-group`, `radio-group-item` and `radio-group-indicator`.
- Items carry the `peer` class, so a following `Label` can react to their state.
- Uses the `primary`, `primary-foreground` and `destructive` colors, and `opacity-50` when disabled.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-radio-group--default.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "default", label: "Default" },
  { value: "comfortable", label: "Comfortable" },
  { value: "compact", label: "Compact" },
]

function RadioGroupDemo({
  disabled = false,
  disabledItem,
}: {
  disabled?: boolean
  disabledItem?: string
}) {
  const [value, setValue] = React.useState("comfortable")
  return (
    <RadioGroup value={value} onValueChange={setValue} disabled={disabled}>
      {options.map((option) => {
        const isDisabled = disabled || option.value === disabledItem
        return (
          <View key={option.value} className="flex-row items-center gap-3">
            <RadioGroupItem
              value={option.value}
              accessibilityLabelledBy={`radio-${option.value}`}
              disabled={isDisabled}
            />
            <Label
              nativeID={`radio-${option.value}`}
              disabled={isDisabled}
              onPress={() => setValue(option.value)}
            >
              {option.label}
            </Label>
          </View>
        )
      })}
    </RadioGroup>
  )
}

export function RadioGroupExample() {
  return (
    <RadioGroupDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct RadioGroupOption {
  let value: String
  let label: LocalizedStringKey
}

let radioGroupOptions = [
  RadioGroupOption(value: "default", label: "Default"),
  RadioGroupOption(value: "comfortable", label: "Comfortable"),
  RadioGroupOption(value: "compact", label: "Compact"),
]

struct RadioGroupDemo: View {
  var disabled = false
  var disabledItem: String?

  @State private var value = "comfortable"

  var body: some View {
    RadioGroup(selection: $value) {
      ForEach(radioGroupOptions, id: \.value) { option in
        RadioGroupItem(option.label, value: option.value)
          .disabled(disabled || option.value == disabledItem)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem

class RadioGroupOption(val value: String, val label: String, val description: String = "")

val radioGroupOptions = listOf(
    RadioGroupOption("default", "Default"),
    RadioGroupOption("comfortable", "Comfortable"),
    RadioGroupOption("compact", "Compact"),
)

@Composable
fun Modifier.radioGroupTap(enabled: Boolean, onTap: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    enabled = enabled,
    onClick = onTap,
)

@Composable
fun RadioGroupDemo(disabled: Boolean = false, disabledItem: String? = null) {
    var value by rememberSaveable { mutableStateOf("comfortable") }
    RadioGroup(value = value, onValueChange = { value = it }, enabled = !disabled) {
        radioGroupOptions.forEach { option ->
            val isDisabled = disabled || option.value == disabledItem
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                RadioGroupItem(
                    option.value,
                    Modifier.semantics { contentDescription = option.label },
                    enabled = !isDisabled,
                )
                Label(option.label, Modifier.radioGroupTap(!isDisabled) { value = option.value }, enabled = !isDisabled)
            }
        }
    }
}
```

`RadioGroup` is a `View` with the `radiogroup` role that holds the selected value. Each `RadioGroupItem` is a small round `Pressable` with the `radio` role; when checked it fills with the primary color and shows a dot. Items have an enlarged hit area so they are easy to press.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/radio-group
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add radio-group
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add radio-group --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add radio-group --platform kotlin
```

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: the primitives `components/primitives/radio.tsx`, `components/primitives/radio-group.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/radio-group.tsx`:

```tsx title="uniwind/components/ui/radio-group.tsx"
import { cn } from "cn"
import { View } from "react-native"
import * as RadioPrimitive from "@/components/primitives/radio"
import {
  RadioGroup as RadioGroupPrimitive,
  type RadioGroupProps,
} from "@/components/primitives/radio-group"
import { resolveClassName } from "@/components/primitives/use-render"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

function RadioGroup<Value = string>({
  className,
  ...props
}: RadioGroupProps<Value>) {
  return (
    <RadioGroupPrimitive<Value>
      className={(state) =>
        cn("w-full gap-3", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function RadioGroupItem<Value = string>({
  className,
  invalid,
  ...props
}: RadioPrimitive.RootProps<Value> & { invalid?: boolean }) {
  return (
    <RadioPrimitive.Root<Value>
      className={(state) =>
        cn(
          "aspect-square size-4 shrink-0 items-center justify-center rounded-full border",
          state.checked
            ? "border-primary bg-primary"
            : cn(
                "border-foreground/25 bg-background shadow-xs dark:border-transparent dark:bg-border dark:shadow-none",
                invalid && "border-destructive dark:border-destructive"
              ),
          state.disabled ? "opacity-50" : "active:opacity-80",
          resolveClassName(className, state)
        )
      }
      hitSlop={HIT_SLOP}
      {...props}
    >
      <RadioPrimitive.Indicator className="items-center justify-center">
        <View className="size-2 rounded-full bg-primary-foreground dark:size-2.5" />
      </RadioPrimitive.Indicator>
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, RadioGroupItem }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: the primitives `components/primitives/radio.tsx`, `components/primitives/radio-group.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/radio-group.tsx`:

```tsx title="nativewind/components/ui/radio-group.tsx"
import { cn } from "cn"
import { View } from "react-native"
import * as RadioPrimitive from "@/components/primitives/radio"
import {
  RadioGroup as RadioGroupPrimitive,
  type RadioGroupProps,
} from "@/components/primitives/radio-group"
import { resolveClassName } from "@/components/primitives/use-render"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

function RadioGroup<Value = string>({
  className,
  ...props
}: RadioGroupProps<Value>) {
  return (
    <RadioGroupPrimitive<Value>
      className={(state) =>
        cn("w-full gap-3", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function RadioGroupItem<Value = string>({
  className,
  invalid,
  ...props
}: RadioPrimitive.RootProps<Value> & { invalid?: boolean }) {
  return (
    <RadioPrimitive.Root<Value>
      className={(state) =>
        cn(
          "aspect-square size-4 shrink-0 items-center justify-center rounded-full border",
          state.checked
            ? "border-primary bg-primary"
            : cn(
                "border-foreground-25 bg-background shadow-sm dark:border-transparent dark:bg-border dark:shadow-none",
                invalid && "border-destructive dark:border-destructive"
              ),
          state.disabled ? "opacity-50" : "active:opacity-80",
          resolveClassName(className, state)
        )
      }
      hitSlop={HIT_SLOP}
      {...props}
    >
      <RadioPrimitive.Indicator className="items-center justify-center">
        <View className="size-2 rounded-full bg-primary-foreground dark:size-2.5" />
      </RadioPrimitive.Indicator>
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, RadioGroupItem }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: the primitives `components/primitives/radio.tsx`, `components/primitives/radio-group.tsx`, `components/primitives/use-render.tsx`.

2. Copy the source into `components/ui/radio-group.tsx`:

```tsx title="stylesheet/components/ui/radio-group.tsx"
import {
  Pressable,
  type StyleProp,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import * as RadioPrimitive from "@/components/primitives/radio"
import {
  RadioGroup as RadioGroupPrimitive,
  type RadioGroupProps,
} from "@/components/primitives/radio-group"
import { resolveStyle } from "@/components/primitives/use-render"
import { shadow, type ThemeColors, useTheme } from "@/components/ui/theme"

const HIT_SLOP = { top: 8, bottom: 8, left: 12, right: 12 }

const styles = StyleSheet.create({
  root: { width: "100%", gap: 12 },
  item: {
    aspectRatio: 1,
    width: 16,
    height: 16,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 9999,
    borderWidth: 1,
  },
  indicator: { alignItems: "center", justifyContent: "center" },
  dot: { width: 8, height: 8, borderRadius: 9999 },
  dotDark: { width: 10, height: 10 },
})

function radioStyle(
  colors: ThemeColors,
  isDark: boolean,
  state: RadioPrimitive.RootState,
  pressed: boolean,
  invalid: boolean | undefined
): ViewStyle {
  let opacity = 1
  if (pressed) opacity = 0.8
  if (state.disabled) opacity = 0.5
  if (state.checked) {
    return {
      borderColor: colors.primary,
      backgroundColor: colors.primary,
      opacity,
    }
  }
  if (isDark) {
    return {
      borderColor: invalid ? colors.destructive : "transparent",
      backgroundColor: colors.border,
      opacity,
    }
  }
  return {
    borderColor: invalid ? colors.destructive : `${colors.foreground}40`,
    backgroundColor: colors.background,
    opacity,
    ...shadow.xs,
  }
}

function RadioGroup<Value = string>({
  style,
  ...props
}: RadioGroupProps<Value>) {
  return (
    <RadioGroupPrimitive<Value>
      style={(state) => [
        styles.root,
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

function RadioGroupItem<Value = string>({
  style,
  invalid,
  ...props
}: RadioPrimitive.RootProps<Value> & { invalid?: boolean }) {
  const { colors, isDark } = useTheme()
  return (
    <RadioPrimitive.Root<Value>
      hitSlop={HIT_SLOP}
      {...props}
      render={(renderProps, state) => (
        <Pressable
          {...renderProps}
          style={({ pressed }) => [
            styles.item,
            radioStyle(colors, isDark, state, pressed, invalid),
            resolveStyle(style, state) as StyleProp<ViewStyle>,
          ]}
        />
      )}
    >
      <RadioPrimitive.Indicator style={styles.indicator}>
        <View
          style={[
            styles.dot,
            isDark && styles.dotDark,
            { backgroundColor: colors.primaryForeground },
          ]}
        />
      </RadioPrimitive.Indicator>
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, RadioGroupItem }
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/RadioGroup.swift"
import SwiftUI

struct RadioGroupContext {
  var selection: AnyHashable?
  var select: (AnyHashable) -> Void
}

extension EnvironmentValues {
  @Entry var radioGroup: RadioGroupContext?
}

public struct RadioGroup<Value: Hashable, Content: View>: View {
  @Binding var selection: Value?
  let content: Content

  public init(selection: Binding<Value?>, @ViewBuilder content: () -> Content) {
    _selection = selection
    self.content = content()
  }

  public init(selection: Binding<Value>, @ViewBuilder content: () -> Content) {
    _selection = Binding(
      get: { selection.wrappedValue },
      set: { if let value = $0 { selection.wrappedValue = value } }
    )
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .environment(\.radioGroup, RadioGroupContext(
      selection: selection.map(AnyHashable.init),
      select: { value in
        if let value = value.base as? Value {
          selection = value
        }
      }
    ))
    .accessibilityElement(children: .contain)
  }
}

public struct RadioGroupItem<Value: Hashable, Label: View>: View {
  let value: Value
  let label: Label

  @Environment(\.radioGroup) private var group
  @Environment(\.colorScheme) private var colorScheme

  public init(value: Value, @ViewBuilder label: () -> Label) {
    self.value = value
    self.label = label()
  }

  public var body: some View {
    let checked = group?.selection == AnyHashable(value)
    HapticButton(style: PressReader { pressed in
      HStack(spacing: 12) {
        SelectionIndicator(checked: checked, pressed: pressed, cornerRadius: 8) {
          Circle()
            .fill(EsuissColor.primaryForeground)
            .frame(width: colorScheme == .dark ? 10 : 8, height: colorScheme == .dark ? 10 : 8)
        }
        label
      }
      .contentShape(Rectangle().inset(by: -8))
    }) {
      guard let group, !checked else { return false }
      group.select(AnyHashable(value))
      return true
    }
    .accessibilityElement(children: .combine)
    .accessibilityAddTraits(checked ? .isSelected : [])
  }
}

extension RadioGroupItem where Label == EmptyView {
  public init(value: Value) {
    self.init(value: value) { EmptyView() }
  }
}

extension RadioGroupItem where Label == FieldLabel<Text> {
  public init(_ title: LocalizedStringKey, value: Value) {
    self.init(value: value) { FieldLabel(title) }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Label.swift`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/RadioGroup.kt"
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow

private class RadioGroupState(
    val selected: Any?,
    val onSelect: (Any?) -> Unit,
    val enabled: Boolean,
    val invalid: Boolean,
)

private val LocalRadioGroupState = staticCompositionLocalOf<RadioGroupState?> { null }

@Suppress("UNCHECKED_CAST")
@Composable
fun <T> RadioGroup(
    value: T?,
    onValueChange: (T) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = LocalFieldState.current.enabled,
    invalid: Boolean = LocalFieldState.current.invalid,
    content: @Composable ColumnScope.() -> Unit,
) {
    val state = RadioGroupState(value, { onValueChange(it as T) }, enabled, invalid)
    CompositionLocalProvider(LocalRadioGroupState provides state) {
        Column(
            modifier = modifier.fillMaxWidth().selectableGroup(),
            verticalArrangement = Arrangement.spacedBy(12.dp),
            content = content,
        )
    }
}

@Composable
fun <T> RadioGroupItem(
    value: T,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    invalid: Boolean = false,
    interactionSource: MutableInteractionSource? = null,
    label: (@Composable () -> Unit)? = null,
) {
    val colors = EsuissTheme.colors
    val group = LocalRadioGroupState.current
    val selected = group?.selected == value
    val active = enabled && (group?.enabled ?: true)
    val error = invalid || (group?.invalid ?: false)
    val source = interactionSource ?: remember { MutableInteractionSource() }
    val pressed by source.collectIsPressedAsState()
    val haptics = rememberHaptics()
    val look = selectionControlLook(colors, selected, active, error, pressed)
    val select = Modifier
        .selectable(
            selected = selected,
            interactionSource = source,
            indication = null,
            enabled = active && group != null,
            role = Role.RadioButton,
            onClick = {
                if (group != null && !selected) haptics.selection()
                group?.onSelect(value)
            },
        )
        .then(if (error) Modifier.semantics { error("Invalid") } else Modifier)
    val control = @Composable { controlModifier: Modifier, alpha: Float ->
        Box(
            modifier = controlModifier
                .size(16.dp)
                .alpha(alpha)
                .then(if (look.shadow) Modifier.shadow(FormShadowXs, CircleShape) else Modifier)
                .background(look.background, CircleShape)
                .border(1.dp, look.border, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            if (selected) {
                Box(Modifier.size(if (colors.isDark) 10.dp else 8.dp).background(colors.primaryForeground, CircleShape))
            }
        }
    }
    if (label == null) {
        control(modifier.then(select).formTouchTarget(), look.alpha)
    } else {
        val color = colors.foreground
        Row(
            modifier = modifier.then(select).heightIn(min = 24.dp).alpha(if (active) 1f else 0.5f),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            control(Modifier, if (pressed && active) 0.8f else 1f)
            CompositionLocalProvider(
                LocalContentStyle provides Typography.base.copy(color = color),
                LocalContentColor provides color,
                content = label,
            )
        }
    }
}
```

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
```

```tsx
<RadioGroupDemo />
```

Give every `RadioGroupItem` a unique `value`. Control the group with `value` and `onValueChange(value, details)`, or leave it uncontrolled with `defaultValue`. Values are generic, so they can be strings or other types.

Put a `Label` next to each item and link it through `nativeID` and `accessibilityLabelledBy`. Set `disabled` on the group to disable all items, or on a single item. Set `invalid` on items to show the destructive border.

## Examples

### With descriptions

Options with a label and a line of description each.

![Preview](https://ui.suiss.app/previews/components-radio-group--with-descriptions.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Text } from "@/components/ui/text"

const plans = [
  {
    value: "starter",
    label: "Starter",
    description: "For individuals getting started.",
  },
  {
    value: "pro",
    label: "Pro",
    description: "For growing teams that need more.",
  },
  {
    value: "enterprise",
    label: "Enterprise",
    description: "For large organizations with custom needs.",
  },
]

function WithDescriptionsDemo() {
  const [value, setValue] = React.useState("pro")
  return (
    <RadioGroup value={value} onValueChange={setValue} className="gap-4">
      {plans.map((plan) => (
        <View key={plan.value} className="flex-row items-start gap-3">
          <RadioGroupItem
            value={plan.value}
            accessibilityLabelledBy={`plan-${plan.value}`}
          />
          <View className="flex-1 gap-1">
            <Label
              nativeID={`plan-${plan.value}`}
              onPress={() => setValue(plan.value)}
            >
              {plan.label}
            </Label>
            <Text className="text-sm text-muted-foreground">
              {plan.description}
            </Text>
          </View>
        </View>
      ))}
    </RadioGroup>
  )
}

export function RadioGroupWithDescriptionsExample() {
  return (
    <WithDescriptionsDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct RadioGroupPlan {
  let value: String
  let label: LocalizedStringKey
  let description: LocalizedStringKey
}

let radioGroupPlans = [
  RadioGroupPlan(value: "starter", label: "Starter", description: "For individuals getting started."),
  RadioGroupPlan(value: "pro", label: "Pro", description: "For growing teams that need more."),
  RadioGroupPlan(value: "enterprise", label: "Enterprise", description: "For large organizations with custom needs."),
]

struct RadioGroupDescriptionsDemo: View {
  @State private var value = "pro"

  var body: some View {
    RadioGroup(selection: $value) {
      ForEach(radioGroupPlans, id: \.value) { plan in
        RadioGroupItem(value: plan.value) {
          VStack(alignment: .leading, spacing: 4) {
            FieldLabel(plan.label)
            Text(plan.description)
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .frame(maxWidth: .infinity, alignment: .leading)
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

class RadioGroupOption(val value: String, val label: String, val description: String = "")

val radioGroupPlans = listOf(
    RadioGroupOption("starter", "Starter", "For individuals getting started."),
    RadioGroupOption("pro", "Pro", "For growing teams that need more."),
    RadioGroupOption("enterprise", "Enterprise", "For large organizations with custom needs."),
)

@Composable
fun Modifier.radioGroupTap(enabled: Boolean, onTap: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    enabled = enabled,
    onClick = onTap,
)

@Composable
fun RadioGroupWithDescriptions() {
    var value by rememberSaveable { mutableStateOf("pro") }
    val colors = EsuissTheme.colors
    RadioGroup(value = value, onValueChange = { value = it }) {
        Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
            radioGroupPlans.forEach { plan ->
                Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
                    RadioGroupItem(plan.value, Modifier.semantics { contentDescription = plan.label })
                    Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
                        Label(plan.label, Modifier.radioGroupTap(true) { value = plan.value })
                        Text(plan.description, style = Typography.sm, color = colors.mutedForeground)
                    }
                }
            }
        }
    }
}
```

### Disabled item

A group with one disabled option.

![Preview](https://ui.suiss.app/previews/components-radio-group--disabled-item.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "default", label: "Default" },
  { value: "comfortable", label: "Comfortable" },
  { value: "compact", label: "Compact" },
]

function RadioGroupDemo({
  disabled = false,
  disabledItem,
}: {
  disabled?: boolean
  disabledItem?: string
}) {
  const [value, setValue] = React.useState("comfortable")
  return (
    <RadioGroup value={value} onValueChange={setValue} disabled={disabled}>
      {options.map((option) => {
        const isDisabled = disabled || option.value === disabledItem
        return (
          <View key={option.value} className="flex-row items-center gap-3">
            <RadioGroupItem
              value={option.value}
              accessibilityLabelledBy={`radio-${option.value}`}
              disabled={isDisabled}
            />
            <Label
              nativeID={`radio-${option.value}`}
              disabled={isDisabled}
              onPress={() => setValue(option.value)}
            >
              {option.label}
            </Label>
          </View>
        )
      })}
    </RadioGroup>
  )
}

export function RadioGroupDisabledItemExample() {
  return (
    <RadioGroupDemo disabledItem="compact" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct RadioGroupDisabledItemExample: View {
  var body: some View {
    RadioGroupDemo(disabledItem: "compact")
  }
}

struct RadioGroupOption {
  let value: String
  let label: LocalizedStringKey
}

let radioGroupOptions = [
  RadioGroupOption(value: "default", label: "Default"),
  RadioGroupOption(value: "comfortable", label: "Comfortable"),
  RadioGroupOption(value: "compact", label: "Compact"),
]

struct RadioGroupDemo: View {
  var disabled = false
  var disabledItem: String?

  @State private var value = "comfortable"

  var body: some View {
    RadioGroup(selection: $value) {
      ForEach(radioGroupOptions, id: \.value) { option in
        RadioGroupItem(option.label, value: option.value)
          .disabled(disabled || option.value == disabledItem)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem

@Composable
fun RadioGroupDisabledItemExample() {
    RadioGroupDemo(disabledItem = "compact")
}

class RadioGroupOption(val value: String, val label: String, val description: String = "")

val radioGroupOptions = listOf(
    RadioGroupOption("default", "Default"),
    RadioGroupOption("comfortable", "Comfortable"),
    RadioGroupOption("compact", "Compact"),
)

@Composable
fun Modifier.radioGroupTap(enabled: Boolean, onTap: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    enabled = enabled,
    onClick = onTap,
)

@Composable
fun RadioGroupDemo(disabled: Boolean = false, disabledItem: String? = null) {
    var value by rememberSaveable { mutableStateOf("comfortable") }
    RadioGroup(value = value, onValueChange = { value = it }, enabled = !disabled) {
        radioGroupOptions.forEach { option ->
            val isDisabled = disabled || option.value == disabledItem
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                RadioGroupItem(
                    option.value,
                    Modifier.semantics { contentDescription = option.label },
                    enabled = !isDisabled,
                )
                Label(option.label, Modifier.radioGroupTap(!isDisabled) { value = option.value }, enabled = !isDisabled)
            }
        }
    }
}
```

### Disabled

A fully disabled group.

![Preview](https://ui.suiss.app/previews/components-radio-group--disabled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "default", label: "Default" },
  { value: "comfortable", label: "Comfortable" },
  { value: "compact", label: "Compact" },
]

function RadioGroupDemo({
  disabled = false,
  disabledItem,
}: {
  disabled?: boolean
  disabledItem?: string
}) {
  const [value, setValue] = React.useState("comfortable")
  return (
    <RadioGroup value={value} onValueChange={setValue} disabled={disabled}>
      {options.map((option) => {
        const isDisabled = disabled || option.value === disabledItem
        return (
          <View key={option.value} className="flex-row items-center gap-3">
            <RadioGroupItem
              value={option.value}
              accessibilityLabelledBy={`radio-${option.value}`}
              disabled={isDisabled}
            />
            <Label
              nativeID={`radio-${option.value}`}
              disabled={isDisabled}
              onPress={() => setValue(option.value)}
            >
              {option.label}
            </Label>
          </View>
        )
      })}
    </RadioGroup>
  )
}

export function RadioGroupDisabledExample() {
  return (
    <RadioGroupDemo disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct RadioGroupDisabledExample: View {
  var body: some View {
    RadioGroupDemo(disabled: true)
  }
}

struct RadioGroupOption {
  let value: String
  let label: LocalizedStringKey
}

let radioGroupOptions = [
  RadioGroupOption(value: "default", label: "Default"),
  RadioGroupOption(value: "comfortable", label: "Comfortable"),
  RadioGroupOption(value: "compact", label: "Compact"),
]

struct RadioGroupDemo: View {
  var disabled = false
  var disabledItem: String?

  @State private var value = "comfortable"

  var body: some View {
    RadioGroup(selection: $value) {
      ForEach(radioGroupOptions, id: \.value) { option in
        RadioGroupItem(option.label, value: option.value)
          .disabled(disabled || option.value == disabledItem)
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem

@Composable
fun RadioGroupDisabledExample() {
    RadioGroupDemo(disabled = true)
}

class RadioGroupOption(val value: String, val label: String, val description: String = "")

val radioGroupOptions = listOf(
    RadioGroupOption("default", "Default"),
    RadioGroupOption("comfortable", "Comfortable"),
    RadioGroupOption("compact", "Compact"),
)

@Composable
fun Modifier.radioGroupTap(enabled: Boolean, onTap: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    enabled = enabled,
    onClick = onTap,
)

@Composable
fun RadioGroupDemo(disabled: Boolean = false, disabledItem: String? = null) {
    var value by rememberSaveable { mutableStateOf("comfortable") }
    RadioGroup(value = value, onValueChange = { value = it }, enabled = !disabled) {
        radioGroupOptions.forEach { option ->
            val isDisabled = disabled || option.value == disabledItem
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                RadioGroupItem(
                    option.value,
                    Modifier.semantics { contentDescription = option.label },
                    enabled = !isDisabled,
                )
                Label(option.label, Modifier.radioGroupTap(!isDisabled) { value = option.value }, enabled = !isDisabled)
            }
        }
    }
}
```

### Invalid

Items with the `invalid` border.

![Preview](https://ui.suiss.app/previews/components-radio-group--invalid.light.webp)

```tsx
import { View } from "react-native"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const options = [
  { value: "default", label: "Default" },
  { value: "comfortable", label: "Comfortable" },
  { value: "compact", label: "Compact" },
]

export function RadioGroupInvalidExample() {
  return (
    <RadioGroup className="gap-3">
      {options.map((option) => (
        <View key={option.value} className="flex-row items-center gap-3">
          <RadioGroupItem
            invalid
            value={option.value}
            accessibilityLabelledBy={`radio-invalid-${option.value}`}
          />
          <Label nativeID={`radio-invalid-${option.value}`}>
            {option.label}
          </Label>
        </View>
      ))}
    </RadioGroup>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct RadioGroupInvalidExample: View {
  var body: some View {
    RadioGroup(selection: .constant(String?.none)) {
      ForEach(radioGroupOptions, id: \.value) { option in
        RadioGroupItem(option.label, value: option.value)
      }
    }
    .invalid()
  }
}

struct RadioGroupOption {
  let value: String
  let label: LocalizedStringKey
}

let radioGroupOptions = [
  RadioGroupOption(value: "default", label: "Default"),
  RadioGroupOption(value: "comfortable", label: "Comfortable"),
  RadioGroupOption(value: "compact", label: "Compact"),
]
```

```kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.RadioGroup
import com.esuiss.ui.components.RadioGroupItem

class RadioGroupOption(val value: String, val label: String, val description: String = "")

val radioGroupOptions = listOf(
    RadioGroupOption("default", "Default"),
    RadioGroupOption("comfortable", "Comfortable"),
    RadioGroupOption("compact", "Compact"),
)

@Composable
fun Modifier.radioGroupTap(enabled: Boolean, onTap: () -> Unit): Modifier = clickable(
    interactionSource = remember { MutableInteractionSource() },
    indication = null,
    enabled = enabled,
    onClick = onTap,
)

@Composable
fun RadioGroupInvalid() {
    var value by rememberSaveable { mutableStateOf<String?>(null) }
    RadioGroup(value = value, onValueChange = { value = it }) {
        radioGroupOptions.forEach { option ->
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                RadioGroupItem(option.value, Modifier.semantics { contentDescription = option.label }, invalid = true)
                Label(option.label)
            }
        }
    }
}
```

## API Reference

### RadioGroup

The container that holds the selected value.

Also accepts the props of `RadioGroupProps<Value>`.

### RadioGroupItem

A single radio button with its checked indicator.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `invalid` | `boolean` | - | Shows a destructive border while unchecked. |

Also accepts the props of `RadioPrimitive.RootProps<Value>`.

## Accessibility

- The group has the `radiogroup` role and reports `disabled` in `accessibilityState`.
- Each item has the `radio` role and reports `checked` and `disabled` in `accessibilityState`.
- The indicator is hidden from screen readers; label each item with a linked `Label` or an `accessibilityLabel`.

## Notes

- Selecting an item triggers a selection haptic when a haptics adapter is provided through `HapticsContext`.
- Uses the `bg-primary`, `bg-primary-foreground` and `border-destructive` tokens, with `opacity-50` when disabled.
- Light and dark colors follow the system appearance; in dark mode unchecked items drop the border and shadow.

