Radio group - headless

Styled SvRadioGroup plus a custom segmented control (with a disabled option), one bound value.

A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.

About this example

The headless radio group core of the SvGrid UI kit in Svelte 5. createRadioGroup is the state machine behind SvRadioGroup: single selection, a roving tabindex, arrow-key navigation and the ARIA radiogroup, exposed as groupProps and radioProps. The same core drives the styled component and a custom segmented control with a disabled option, both bound to one value.

Headless radio group - the SvGrid UI kit is headless-first + render-ready, just like the grid. createRadioGroup is the state machine behind <SvRadioGroup>: single-select + roving tabindex + arrow-key navigation + ARIA radiogroup, exposed as **prop-getters** you spread onto YOUR OWN markup. Below, the same core drives the styled component AND a totally custom render, both bound to one value.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

How does the roving tabindex work?

radioProps(i) gives tabindex 0 to the selected radio and -1 to the rest, so Tab enters the group once and arrow keys move selection and focus together.

How are disabled options skipped?

The core excludes them from arrow navigation and radioProps marks them aria-disabled; the custom segmented control greys the segment.

Does selection wrap?

Yes. Pressing Down or Right on the last option moves to the first and Up or Left on the first moves to the last, as native radios do.

Related documentation

Related articles

  • SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
  • SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
  • Building a Project / Task Board with a Svelte Data Grid - How to build a task management grid with grouping by status or assignee, inline edits, subtask tree rows, and saved views - without reaching for a dedicated project management tool.

Source code (274-headless-radiogroup.svelte)

<script lang="ts">
  /**
   * Headless radio group - the SvGrid UI kit is headless-first + render-ready,
   * just like the grid. `createRadioGroup` is the state machine behind
   * <SvRadioGroup>: single-select + roving tabindex + arrow-key navigation +
   * ARIA `radiogroup`, exposed as **prop-getters** you spread onto YOUR OWN
   * markup. Below, the same core drives the styled component AND a totally
   * custom render, both bound to one value.
   */
  import { SvRadioGroup, createRadioGroup, type RadioOption } from '@svgrid/grid'

  const options: RadioOption[] = [
    { value: 'card', label: 'Credit card' },
    { value: 'paypal', label: 'PayPal' },
    { value: 'wire', label: 'Wire transfer' },
    { value: 'crypto', label: 'Crypto', disabled: true },
  ]

  let value = $state<string | number | null>('card')

  // The headless core - identical behavior, our own DOM.
  const rg = createRadioGroup({
    options: () => options,
    value: () => value,
    onChange: (v) => (value = v),
  })
</script>

<div class="wrap">
  <header>
    <h2>Headless radio group</h2>
    <p>
      Every editor ships a headless core + a styled component - the same split as the grid
      (<code>createSvGrid</code> / <code>&lt;SvGrid&gt;</code>). Here <code>createRadioGroup</code>
      drives both renders below; roving focus, keyboard and ARIA come from the core, the markup is yours.
    </p>
  </header>

  <div class="cols">
    <section>
      <h3>Styled <code>&lt;SvRadioGroup&gt;</code></h3>
      <SvRadioGroup {options} {value} ariaLabel="Payment method" onChange={(v) => (value = v)} />
    </section>

    <section>
      <h3>Your markup, same core</h3>
      <div class="segset" {...rg.groupProps()}>
        {#each options as opt (opt.value)}
          <button
            class="seg"
            class:on={rg.isChecked(opt)}
            class:off={opt.disabled}
            {...rg.radioProps(opt)}
          >{opt.label}</button>
        {/each}
      </div>
      <p class="hint">Tab in, then arrow keys move + select, roving focus follows - all from <code>createRadioGroup</code>.</p>
    </section>
  </div>

  <aside class="readout">
    <h3>Value</h3>
    <div class="tags"><span class="tag">{value}</span></div>
  </aside>
</div>

<style>
  .wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
  code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
  .cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
  section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  /* Fully custom render - nothing shared with SvRadioGroup but the core. */
  .segset {
    display: inline-flex; padding: 4px; gap: 4px; border-radius: 10px; outline: none;
    border: 1px solid var(--sg-border, #e2e8f0); background: var(--sg-row-hover-bg, #f1f5f9);
  }
  .seg {
    padding: 8px 14px; border-radius: 7px; font: inherit; font-size: 13px; font-weight: 550; cursor: pointer;
    border: 0; background: transparent; color: var(--sg-fg, #0f172a); transition: background 0.12s, color 0.12s;
  }
  .seg.on { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }
  .seg.off { opacity: 0.4; cursor: not-allowed; }
  .seg:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #4f46e5)); outline-offset: 2px; }
  .hint { margin: 10px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
  .readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  .tags { display: flex; gap: 6px; flex-wrap: wrap; }
  .tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); }
</style>

View this example on GitHub

More Headless Editors examples

  • Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
  • Combobox - headless - createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.
  • Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
  • Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
  • Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.