GrRadio

Package: @feugene/granularitycoreGroup: forms

A single choice within a group of mutually exclusive options.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the switch stands on its own — an option in a table row, in a card, in a list cell: there is no group around it;
  • the layout is set by the consumer — the options are laid out on a grid of their own rather than in a row or a column;
  • the look of a buttonvariant="button" gives the appearance of GrButton while keeping the radio role;
  • the label is complex — the slot accepts markup: a price, a badge, an icon.

When to take something else

NeedTake
There are several options and they stand side by sideGrRadioGroup
Several can be selectedGrCheckbox
There are 2–5 options and they switch a viewGrSegmented
There are more than seven optionsGrSelect

Outside GrRadioGroup the keyboard contract of the radiogroup pattern does not work: the arrows between options are walked by the container. A single GrRadio is a deliberate refusal of it for the sake of a layout of your own rather than a simplification.

The keyboard: the group is one `Tab` stop

KeyWhat it does
Tabenters the group and leaves it; inside the group there is exactly one stop (a roving tabindex)
/ the next option, in a ring
/ the previous option, in a ring
Home / Endthe first / the last available option
Space, Enterselect the current one (for a standalone switch)

The selection travels with the focus — that is what the radiogroup pattern requires. Disabled options are skipped: they take part neither in the roving tabindex nor in the walk with the arrows.

The state lives in GrRadioGroup (register / rovingValue / moveSelection / selectEdge in the context), and GrRadio asks the group whether it is the focusable one right now. A standalone switch outside a group remains an ordinary Tab stop.

The label and the description

<GrRadio value="pro">
  Pro
  <template #description>
    Charged monthly, can be cancelled at any moment
  </template>
</GrRadio>

The label of a selected option is --gr-fg, of an unselected one --gr-muted-fg: before that any of them was dimmed, and the selected option was not distinguished by text at all.

The #description slot is linked to the switch through aria-describedby — otherwise the description does not exist for a screen reader.

The values

value and modelValue are string | number | boolean (GrRadioValue). Enumerations in real forms are usually an id as a number or a flag. Objects are deliberately not included: the value travels into data-value and into the hidden input of a native form, which means it has to have an unambiguous string representation.

The error and the disabled state

invalid can be set on the switch itself or on the whole group — the states add by “or”. The border of the control is coloured --gr-danger, and aria-invalid goes onto the element.

A disabled switch is dimmed with tokens (--gr-muted / --gr-muted-fg) rather than with transparency: opacity dilutes colours tuned to AA. The button variant takes its disabled look from GrButton itself — there it depends on the variant.

The native form

An input[type="hidden"] is rendered beside the switch — only for the selected and non-disabled element and only when name is set, as a native radio would do. Nothing interactive is nested inside an element with role="radio": the role declares its descendants presentational, and a hidden <input type="radio"> would break the widget for a screen reader (axe: nested-interactive).

Playground 10

Loading…

Code
<GrRadio />

Install

npm i @feugene/granularity

Import

import { GrRadio } from '@feugene/granularity/components/GrRadio'

API

Props

PropTypedefaultDescription
variantGrRadioVariant | undefined"radiobox"
modelValueGrRadioValue | undefinedundefined
disabledboolean | undefinedundefined
invalidboolean | undefinedfalseThe visual and ARIA state of an error. It adds up with the `invalid` of the group.
requiredboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefined
formstring | undefinedundefined
idstring | undefinedundefined
buttonVariantGrButtonVariant | undefined"outline"
buttonTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"neutral"
selectedButtonVariantGrButtonVariant | undefined"primary"
selectedButtonTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"
valuerequiredGrRadioValue

Slots

SlotTypeDescription
defaultanyThe label of the radio instead of the `label` prop.
descriptionanyAn explanation under the label.

Events

EventTypeDescription
update:modelValue[value: GrRadioValue]

Examples 3

Descriptions

Выбран тариф #2. Группа — одна остановка `Tab`: внутри работают стрелки, `Home` и `End`, отключённый вариант пропускается.

Descriptions
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrFormField, GrRadio, GrRadioGroup } from '@feugene/granularity'

// Значения числовые: перечисления в реальных формах — это обычно id, а не строка.
const planId = ref(2)

const plans = [
  { id: 1, label: 'Команда', description: 'До 10 участников, общий проект' },
  { id: 2, label: 'Бизнес', description: 'Роли, аудит-лог, приоритетная поддержка' },
  { id: 3, label: 'Enterprise', description: 'Только по договору', disabled: true },
]

const confirmed = ref(false)
const error = computed(() => (confirmed.value && planId.value === 1 ? 'Для аудит-лога нужен тариф выше' : ''))
</script>

<template>
  <div class="grid gap-4">
    <GrFormField label="Тариф" :error="error">
      <GrRadioGroup v-model="planId" name="plan" :invalid="Boolean(error)">
        <GrRadio
          v-for="plan in plans"
          :key="plan.id"
          :value="plan.id"
          :disabled="plan.disabled"
        >
          {{ plan.label }}
          <template #description>
            {{ plan.description }}
          </template>
        </GrRadio>
      </GrRadioGroup>
    </GrFormField>

    <label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
      <input v-model="confirmed" type="checkbox">
      Проверять требование аудит-лога
    </label>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Выбран тариф <span class="font-semibold text-[var(--gr-fg)]">#{{ planId }}</span>.
      Группа — одна остановка `Tab`: внутри работают стрелки, `Home` и `End`, отключённый вариант пропускается.
    </div>
  </div>
</template>

Standalone radios with shared model

Current delivery cadence:
Weekly digest

Standalone
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrRadio } from '@feugene/granularity'

const delivery = ref('weekly')

const selectedLabel = computed(() => {
  const labels: Record<string, string> = {
    daily: 'Daily digest',
    weekly: 'Weekly digest',
    monthly: 'Monthly report',
  }

  return labels[delivery.value] ?? delivery.value
})
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]">
    <div class="grid gap-3">
      <GrRadio v-model="delivery" name="digest-frequency" value="daily">
        Daily digest
      </GrRadio>
      <GrRadio v-model="delivery" name="digest-frequency" value="weekly">
        Weekly digest
      </GrRadio>
      <GrRadio v-model="delivery" name="digest-frequency" value="monthly">
        Monthly report
      </GrRadio>
    </div>

    <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
      Current delivery cadence:
      <div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
        {{ selectedLabel }}
      </div>
    </div>
  </div>
</template>

Button tone for segmented controls

Button-like radios keep the same `v-model` contract while matching toolbar and segmented-control layouts.

Button Variant
<script setup lang="ts">
import { ref } from 'vue'

import { GrRadio } from '@feugene/granularity'

const density = ref('balanced')
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrRadio v-model="density" value="compact" variant="button" size="sm">
        Compact
      </GrRadio>
      <GrRadio v-model="density" value="balanced" variant="button" size="sm">
        Balanced
      </GrRadio>
      <GrRadio v-model="density" value="comfortable" variant="button" size="sm">
        Comfortable
      </GrRadio>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] bg-[var(--gr-muted)]/35 p-4 text-sm text-[var(--gr-muted-fg)]">
      Button-like radios keep the same `v-model` contract while matching toolbar and segmented-control layouts.
    </div>
  </div>
</template>

Accessibility

APG pattern
radio

Full keyboard contract of the package

Component documentationAll components