diff --git a/src/design-system/primitives/Radio/Radio.module.css b/src/design-system/primitives/Radio/Radio.module.css
new file mode 100644
index 0000000..6be91aa
--- /dev/null
+++ b/src/design-system/primitives/Radio/Radio.module.css
@@ -0,0 +1,92 @@
+/* ── RadioGroup layout ────────────────────────────────────────────────────── */
+
+.group {
+ display: flex;
+}
+
+.vertical {
+ flex-direction: column;
+ gap: 8px;
+}
+
+.horizontal {
+ flex-direction: row;
+ gap: 16px;
+}
+
+/* ── RadioItem wrapper ────────────────────────────────────────────────────── */
+
+.wrapper {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.wrapperDisabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+/* ── Hidden native input ──────────────────────────────────────────────────── */
+
+.input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ margin: 0;
+}
+
+/* ── Custom circle ────────────────────────────────────────────────────────── */
+
+.circle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 15px;
+ height: 15px;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ background: var(--bg-raised);
+ flex-shrink: 0;
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
+}
+
+/* Inner dot when checked */
+.circle::after {
+ content: '';
+ display: none;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--amber);
+}
+
+.input:checked + .circle {
+ border-color: var(--amber);
+ background: var(--bg-raised);
+}
+
+.input:checked + .circle::after {
+ display: block;
+}
+
+.input:focus-visible + .circle {
+ border-color: var(--amber);
+ box-shadow: 0 0 0 3px var(--amber-bg);
+}
+
+.input:disabled + .circle {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* ── Label ────────────────────────────────────────────────────────────────── */
+
+.label {
+ font-family: var(--font-body);
+ font-size: 12px;
+ color: var(--text-primary);
+}
diff --git a/src/design-system/primitives/Radio/Radio.test.tsx b/src/design-system/primitives/Radio/Radio.test.tsx
new file mode 100644
index 0000000..2abd697
--- /dev/null
+++ b/src/design-system/primitives/Radio/Radio.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { RadioGroup, RadioItem } from './Radio'
+
+describe('RadioGroup + RadioItem', () => {
+ it('renders all options with correct labels', () => {
+ render(
+
+
+
+
+ ,
+ )
+ expect(screen.getByLabelText('Red')).toBeInTheDocument()
+ expect(screen.getByLabelText('Blue')).toBeInTheDocument()
+ expect(screen.getByLabelText('Green')).toBeInTheDocument()
+ })
+
+ it('marks the current value as checked', () => {
+ render(
+
+
+
+ ,
+ )
+ expect(screen.getByLabelText('Red')).not.toBeChecked()
+ expect(screen.getByLabelText('Blue')).toBeChecked()
+ })
+
+ it('calls onChange with the selected value when clicking an option', async () => {
+ const onChange = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+
+
+ ,
+ )
+ await user.click(screen.getByLabelText('Blue'))
+ expect(onChange).toHaveBeenCalledWith('blue')
+ })
+
+ it('does not call onChange when clicking a disabled item', async () => {
+ const onChange = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+
+
+ ,
+ )
+ await user.click(screen.getByLabelText('Blue'))
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('disabled item has disabled attribute', () => {
+ render(
+
+
+
+ ,
+ )
+ expect(screen.getByLabelText('Blue')).toBeDisabled()
+ expect(screen.getByLabelText('Red')).not.toBeDisabled()
+ })
+
+ it('all inputs share the same name', () => {
+ render(
+
+
+
+
+ ,
+ )
+ const inputs = screen.getAllByRole('radio')
+ inputs.forEach((input) => {
+ expect(input).toHaveAttribute('name', 'size')
+ })
+ })
+
+ it('supports keyboard navigation between items', async () => {
+ const onChange = vi.fn()
+ const user = userEvent.setup()
+ render(
+
+
+
+ ,
+ )
+ const redInput = screen.getByLabelText('Red')
+ redInput.focus()
+ await user.keyboard('{ArrowDown}')
+ expect(onChange).toHaveBeenCalledWith('blue')
+ })
+
+ it('applies horizontal layout class when orientation is horizontal', () => {
+ const { container } = render(
+
+
+
+ ,
+ )
+ const group = container.firstChild as HTMLElement
+ expect(group.className).toMatch(/horizontal/)
+ })
+
+ it('applies vertical layout class by default', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const group = container.firstChild as HTMLElement
+ expect(group.className).toMatch(/vertical/)
+ })
+
+ it('accepts a ReactNode as label', () => {
+ render(
+
+ Bold Red} />
+ ,
+ )
+ expect(screen.getByText('Bold Red')).toBeInTheDocument()
+ })
+
+ it('applies custom className to RadioGroup', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const group = container.firstChild as HTMLElement
+ expect(group.className).toContain('custom-class')
+ })
+})
diff --git a/src/design-system/primitives/Radio/Radio.tsx b/src/design-system/primitives/Radio/Radio.tsx
new file mode 100644
index 0000000..ab53f93
--- /dev/null
+++ b/src/design-system/primitives/Radio/Radio.tsx
@@ -0,0 +1,128 @@
+import styles from './Radio.module.css'
+import { createContext, useContext, type ReactNode } from 'react'
+
+interface RadioGroupContextValue {
+ name: string
+ value: string
+ onChange: (value: string) => void
+}
+
+const RadioGroupContext = createContext(null)
+
+function useRadioGroup(): RadioGroupContextValue {
+ const ctx = useContext(RadioGroupContext)
+ if (!ctx) {
+ throw new Error('RadioItem must be used inside a RadioGroup')
+ }
+ return ctx
+}
+
+// ── RadioGroup ────────────────────────────────────────────────────────────────
+
+interface RadioGroupProps {
+ name: string
+ value: string
+ onChange: (value: string) => void
+ orientation?: 'vertical' | 'horizontal'
+ children?: ReactNode
+ className?: string
+}
+
+export function RadioGroup({
+ name,
+ value,
+ onChange,
+ orientation = 'vertical',
+ children,
+ className,
+}: RadioGroupProps) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+// ── RadioItem ─────────────────────────────────────────────────────────────────
+
+interface RadioItemProps {
+ value: string
+ label: ReactNode
+ disabled?: boolean
+}
+
+export function RadioItem({ value, label, disabled = false }: RadioItemProps) {
+ const ctx = useRadioGroup()
+ const inputId = `radio-${ctx.name}-${value}`
+ const isChecked = ctx.value === value
+
+ function handleChange() {
+ if (!disabled) {
+ ctx.onChange(value)
+ }
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ // Native radio keyboard behaviour fires onChange on ArrowDown/Up/Left/Right
+ // but since we control the value externally we need to relay those events.
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
+ e.preventDefault()
+ const inputs = getGroupInputs(e.currentTarget)
+ const next = getNextEnabled(inputs, e.currentTarget, 1)
+ if (next) {
+ ctx.onChange(next.value)
+ next.focus()
+ }
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
+ e.preventDefault()
+ const inputs = getGroupInputs(e.currentTarget)
+ const prev = getNextEnabled(inputs, e.currentTarget, -1)
+ if (prev) {
+ ctx.onChange(prev.value)
+ prev.focus()
+ }
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function getGroupInputs(current: HTMLInputElement): HTMLInputElement[] {
+ const group = current.closest('[role="radiogroup"]')
+ if (!group) return []
+ return Array.from(group.querySelectorAll('input[type="radio"]:not(:disabled)'))
+}
+
+function getNextEnabled(
+ inputs: HTMLInputElement[],
+ current: HTMLInputElement,
+ direction: 1 | -1,
+): HTMLInputElement | null {
+ const idx = inputs.indexOf(current)
+ if (idx === -1) return null
+ const next = (idx + direction + inputs.length) % inputs.length
+ return inputs[next] ?? null
+}