Components · Data Entry
Radio
Pick exactly one option from a small mutually-exclusive set, all shown at once.
Examples
Variant
Size
Direction
Disabled
Group · one name = one mutually-exclusive set (the probe is the checked option)
Button variant · segmented connected pills
Card variant · the whole bordered card is the target (checked = brand border + bg-accent fill)
Small size · disabled states
<!-- Paste-and-run: save as .html and open in a browser. No build step.
<aha-radio> is the SAME shared custom element React and Vue consume.
Radios sharing a `name` are mutually exclusive; it emits a composed `change` event. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/tokens.css">
<script type="module">
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-radio.js'; // registers <aha-radio>
</script>
<!-- Wrap the set in role="radiogroup" so assistive tech announces one group.
Arrow keys move + select within it; Tab moves in and out as a single stop. -->
<div role="radiogroup" aria-label="Activity mode" style="display:flex;flex-direction:column;gap:10px">
<aha-radio name="mode" value="poll" checked>Poll</aha-radio>
<aha-radio name="mode" value="quiz">Quiz</aha-radio>
<aha-radio name="mode" value="wordcloud">Word cloud</aha-radio>
</div>
<!-- variant="button" → a segmented control of connected pills (size="small" for a compact row). -->
<div role="radiogroup" aria-label="View" style="display:flex">
<aha-radio name="view" value="grid" variant="button" checked>Grid</aha-radio>
<aha-radio name="view" value="list" variant="button">List</aha-radio>
<aha-radio name="view" value="board" variant="button">Board</aha-radio>
</div>
<!-- variant="card" → the whole bordered card is the target; `description` adds a secondary line.
The selected card gets a brand border + a subtle brand-tint fill. -->
<div role="radiogroup" aria-label="Plan" style="display:flex;flex-direction:column;gap:10px;max-width:360px">
<aha-radio name="plan" value="pro" variant="card" checked
description="Best for teams — unlimited slides and live results.">Pro</aha-radio>
<aha-radio name="plan" value="essential" variant="card"
description="For solo creators getting started.">Essential</aha-radio>
</div>
<script>
// Plain DOM — no framework. Read e.detail.value off the composed CustomEvent.
document.querySelectorAll('aha-radio[name="mode"]').forEach((r) => {
r.addEventListener('change', (e) => console.log('mode:', e.detail.value));
});
</script>
import '@ahaslides-product/design/aha-radio'; // registers <aha-radio>
import { useRef, useEffect } from 'react';
// React 18 needs a thin wrapper; React 19 can use <aha-radio> directly.
function AhaRadio({ checked, value, name, disabled, variant, size, description, onChange, children }) {
const ref = useRef();
useEffect(() => {
const el = ref.current;
el.checked = !!checked;
el.disabled = !!disabled;
const h = (e) => onChange?.(e.detail.value); // composed CustomEvent
el.addEventListener('change', h);
return () => el.removeEventListener('change', h);
});
return <aha-radio ref={ref} name={name} value={value} variant={variant} size={size} description={description}>{children}</aha-radio>;
}
// usage — one `name` makes the set mutually exclusive; wrap in role="radiogroup"
<div role="radiogroup" aria-label="Activity mode">
<AhaRadio name="mode" value="poll" checked={mode === 'poll'} onChange={setMode}>Poll</AhaRadio>
<AhaRadio name="mode" value="quiz" checked={mode === 'quiz'} onChange={setMode}>Quiz</AhaRadio>
</div>
// segmented button group — variant="button" (add size="small" for a compact row)
<div role="radiogroup" aria-label="View" style={{ display: 'flex' }}>
<AhaRadio name="view" value="grid" variant="button" checked={view === 'grid'} onChange={setView}>Grid</AhaRadio>
<AhaRadio name="view" value="list" variant="button" checked={view === 'list'} onChange={setView}>List</AhaRadio>
</div>
// card group — variant="card"; the whole tile is the target, `description` adds a secondary line
<div role="radiogroup" aria-label="Plan" style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 360 }}>
<AhaRadio name="plan" value="pro" variant="card" checked={plan === 'pro'} onChange={setPlan}
description="Best for teams — unlimited slides and live results.">Pro</AhaRadio>
<AhaRadio name="plan" value="essential" variant="card" checked={plan === 'essential'} onChange={setPlan}
description="For solo creators getting started.">Essential</AhaRadio>
</div>
// main.ts — register the element + mark aha-* as custom elements
import '@ahaslides-product/design/aha-radio';
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('aha-');
// Component.vue
<script setup>
import { ref } from 'vue';
const mode = ref('poll');
const view = ref('grid');
const plan = ref('pro');
</script>
<template>
<div role="radiogroup" aria-label="Activity mode">
<aha-radio
name="mode" value="poll"
:checked.prop="mode === 'poll'"
@change="mode = $event.detail.value"
>Poll</aha-radio>
<aha-radio
name="mode" value="quiz"
:checked.prop="mode === 'quiz'"
@change="mode = $event.detail.value"
>Quiz</aha-radio>
</div>
<!-- segmented button group — variant="button" (add size="small" for a compact row) -->
<div role="radiogroup" aria-label="View" style="display:flex">
<aha-radio name="view" value="grid" variant="button" :checked.prop="view === 'grid'" @change="view = $event.detail.value">Grid</aha-radio>
<aha-radio name="view" value="list" variant="button" :checked.prop="view === 'list'" @change="view = $event.detail.value">List</aha-radio>
</div>
<!-- card group — variant="card"; the whole tile is the target, description adds a secondary line -->
<div role="radiogroup" aria-label="Plan" style="display:flex;flex-direction:column;gap:10px;max-width:360px">
<aha-radio name="plan" value="pro" variant="card" description="Best for teams — unlimited slides and live results."
:checked.prop="plan === 'pro'" @change="plan = $event.detail.value">Pro</aha-radio>
<aha-radio name="plan" value="essential" variant="card" description="For solo creators getting started."
:checked.prop="plan === 'essential'" @change="plan = $event.detail.value">Essential</aha-radio>
</div>
</template>
API
| Prop | Type | Default | Notes |
|---|---|---|---|
checked | boolean | false | Controlled selection |
value | string | "" | The value this option contributes to the group |
name | string | — | Groups radios; one `name` = one mutually-exclusive set |
variant | 'dot' | 'button' | 'card' | dot | dot = ring + brand dot; button = segmented connected pill (Radio-Button-Group); card = the whole bordered card is the target (dot + title + optional description), selected card gets a brand border + bg-accent fill |
description | string | — | Card variant only — a secondary line rendered under the title |
size | 'default' | 'small' | default | default = 16px ring / 32px button; small = 14px ring / 24px button |
direction | 'horizontal' | 'vertical' | horizontal | How a group flows; in button variant also picks which edges the connected pills round + share |
disabled | boolean | false | Disables the control |
change | CustomEvent<{value, checked}> | — | Composed event; read e.detail.value |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/aha-radio'; // registers <aha-radio>
Agent feed for this component (absolute, fetchable anywhere): radio.agent.json · radio.md · radio.llms.txt
When to use
When to use
- Radio — exactly one from a small (2–5) mutually-exclusive set, all worth showing at once
- Select — one value from a known set of more than ~5 options — don't crowd the surface
- Checkbox — one or more independent options that don't exclude each other
Pre-select a sensible default rather than leaving the group empty. Labels are sentence case and state the choice — 'Show results after each question', not 'Show Results'. Keep product nouns: Q&A, Word cloud. Reach for variant='button' when the options are short, mutually-exclusive views (Grid/List, Day/Week/Month) that read better as a segmented control. Reach for variant='card' when each option carries a title plus a line of explanation and the whole tile should be the click target (plan pickers, mode choosers).
Surfaces
editor dashboard settings audience
Spec
Ring 16×16 circle · 1px #D4D4D4 border (small: 14×14) · Hover border #D3B4FF (purple-30) · Checked #6A1EBB ring + 8px brand dot (scales in) · Disabled #F1F1F1 fill · #EBEBEB border · Label Plus Jakarta 14/21 (small: 13/20) · Button variant segmented pill · 32px (small 24) · checked = bg-accent + brand border, shared edge collapsed · Card variant bordered card · dot + title + optional description · checked = brand border + bg-accent fill (16px pad, small 12px)