diff --git a/.changeset/wild-pandas-smile.md b/.changeset/wild-pandas-smile.md
new file mode 100644
index 00000000..59749693
--- /dev/null
+++ b/.changeset/wild-pandas-smile.md
@@ -0,0 +1,5 @@
+---
+'@conciv/ui-kit-system': patch
+---
+
+Add the SegmentGroup primitive: an Ark segment group whose selected indicator slides between segments through zag's indicator position variables, plus a switch thumb that now actually travels. Both snap instead of sliding under reduced motion.
diff --git a/packages/ui-kit-system/src/index.tsx b/packages/ui-kit-system/src/index.tsx
index 35bb8f1d..b4481256 100644
--- a/packages/ui-kit-system/src/index.tsx
+++ b/packages/ui-kit-system/src/index.tsx
@@ -30,6 +30,7 @@ export {Toast, ToastGroup, createToaster, type ToasterInstance} from './toast.js
export {Avatar} from './avatar.js'
export {RelativeTime} from './relative-time.js'
export {Tabs} from './tabs.js'
+export {SegmentGroup} from './segment-group.js'
export {Switch} from './switch.js'
export {Swap} from './swap.js'
export {Presence} from './presence.js'
diff --git a/packages/ui-kit-system/src/segment-group.stories.tsx b/packages/ui-kit-system/src/segment-group.stories.tsx
new file mode 100644
index 00000000..472d0a67
--- /dev/null
+++ b/packages/ui-kit-system/src/segment-group.stories.tsx
@@ -0,0 +1,118 @@
+import {createSignal, For} from 'solid-js'
+import type {Meta, StoryObj} from 'storybook-solidjs-vite'
+import {expect, within, userEvent, waitFor} from 'storybook/test'
+import Monitor from 'lucide-solid/icons/monitor'
+import Moon from 'lucide-solid/icons/moon'
+import Sun from 'lucide-solid/icons/sun'
+import {SegmentGroup} from './segment-group.js'
+
+const meta: Meta = {title: 'ui-kit-system/SegmentGroup'}
+export default meta
+type Story = StoryObj
+
+const DENSITIES = ['Comfortable', 'Cozy', 'Compact']
+
+export const Default: Story = {
+ render: () => {
+ const [density, setDensity] = createSignal('Cozy')
+ return (
+
+
setDensity(details.value ?? 'Cozy')}
+ aria-label="Thread density"
+ >
+
+
+ {(option) => (
+
+ {option}
+
+
+ )}
+
+
+
Density: {density()}
+
+ )
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement)
+ await expect(canvas.getByText('Density: Cozy')).toBeVisible()
+ await userEvent.click(canvas.getByText('Compact'))
+ await waitFor(() => expect(canvas.getByText('Density: Compact')).toBeVisible())
+ },
+}
+
+const SCHEMES = [
+ {value: 'auto', label: 'Auto', icon: Monitor},
+ {value: 'light', label: 'Light', icon: Sun},
+ {value: 'dark', label: 'Dark', icon: Moon},
+]
+
+export const Scheme: Story = {
+ render: () => {
+ const [scheme, setScheme] = createSignal('auto')
+ return (
+
+
setScheme(details.value ?? 'auto')}
+ aria-label="Appearance"
+ >
+
+
+ {(option) => (
+
+
+
+ {option.label}
+
+
+
+ )}
+
+
+
Scheme: {scheme()}
+
+ )
+ },
+ play: async ({canvasElement}) => {
+ const canvas = within(canvasElement)
+ await expect(canvas.getByText('Scheme: auto')).toBeVisible()
+ await userEvent.click(canvas.getByText('Dark'))
+ await waitFor(() => expect(canvas.getByText('Scheme: dark')).toBeVisible())
+ },
+}
+
+export const Vertical: Story = {
+ render: () => (
+
+
+
+ {(section) => (
+
+ {section}
+
+
+ )}
+
+
+ ),
+}
+
+export const Disabled: Story = {
+ render: () => (
+
+
+
+ {(option) => (
+
+ {option}
+
+
+ )}
+
+
+ ),
+}
diff --git a/packages/ui-kit-system/src/segment-group.tsx b/packages/ui-kit-system/src/segment-group.tsx
new file mode 100644
index 00000000..1e69ec83
--- /dev/null
+++ b/packages/ui-kit-system/src/segment-group.tsx
@@ -0,0 +1,32 @@
+import {splitProps, type ComponentProps} from 'solid-js'
+import {SegmentGroup as Ark} from '@ark-ui/solid/segment-group'
+
+const ROOT =
+ 'relative inline-flex items-center gap-0.5 p-0.5 rounded-chat-surface-sm bg-chat-fill-soft [border:1px_solid_var(--chat-line)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[disabled]:opacity-50'
+const INDICATOR =
+ 'rounded-chat-surface-sm bg-chat-fill-strong [border:1px_solid_var(--chat-line-2)] shadow-chat-sm [width:var(--width)] [height:var(--height)] [left:var(--left)] [top:var(--top)] [--transition-duration:200ms] [--transition-timing-function:var(--chat-ease-expo)] motion-reduce:[--transition-duration:0.01ms]'
+const ITEM =
+ 'relative inline-flex items-center justify-center gap-1.5 min-h-8 py-1 px-3 rounded-chat-surface-sm text-[0.8125rem] font-chat text-chat-text-3 whitespace-nowrap cursor-pointer select-none trans-color-bg hover:text-chat-text-2 data-[state=checked]:text-chat-text-hi data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem] data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed'
+const ITEM_TEXT = 'inline-flex items-center gap-1.5'
+
+function Root(props: ComponentProps) {
+ const [local, rest] = splitProps(props, ['class', 'orientation'])
+ return
+}
+
+function Indicator(props: ComponentProps) {
+ const [local, rest] = splitProps(props, ['class'])
+ return
+}
+
+function Item(props: ComponentProps) {
+ const [local, rest] = splitProps(props, ['class'])
+ return
+}
+
+function ItemText(props: ComponentProps) {
+ const [local, rest] = splitProps(props, ['class'])
+ return
+}
+
+export const SegmentGroup = Object.assign({}, Ark, {Root, Indicator, Item, ItemText})
diff --git a/packages/ui-kit-system/src/switch.tsx b/packages/ui-kit-system/src/switch.tsx
index 015824b2..d4a44488 100644
--- a/packages/ui-kit-system/src/switch.tsx
+++ b/packages/ui-kit-system/src/switch.tsx
@@ -6,7 +6,7 @@ const ROOT =
const CONTROL =
'relative inline-flex items-center w-8 h-4.5 rounded-chat-pill bg-chat-fill-strong [border:1px_solid_var(--chat-line)] trans-btn data-[state=checked]:bg-chat-accent data-[state=checked]:[border-color:var(--chat-accent)] data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem]'
const THUMB =
- 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 trans-btn data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent'
+ 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 [transition:translate_140ms_var(--chat-ease-expo),background-color_120ms_var(--chat-ease)] motion-reduce:[transition-property:background-color] data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent'
const LABEL = 'text-[0.8125rem] font-chat text-chat-text-2'
function Root(props: ComponentProps) {
diff --git a/packages/ui-kit-system/test/segment-group.browser.test.tsx b/packages/ui-kit-system/test/segment-group.browser.test.tsx
new file mode 100644
index 00000000..fea5cfbd
--- /dev/null
+++ b/packages/ui-kit-system/test/segment-group.browser.test.tsx
@@ -0,0 +1,94 @@
+import 'virtual:uno.css'
+import {render} from '@solidjs/testing-library'
+import {page, userEvent} from 'vitest/browser'
+import {createSignal, For} from 'solid-js'
+import {expect, it} from 'vitest'
+import {SegmentGroup} from '../src/segment-group.js'
+
+const SCHEMES = ['Auto', 'Light', 'Dark']
+
+function mount(options: {defaultValue?: string; disabled?: string} = {}): () => string | null {
+ const [value, setValue] = createSignal(options.defaultValue ?? null)
+ render(() => (
+ setValue(details.value)}
+ aria-label="Colour scheme"
+ >
+
+
+ {(scheme) => (
+
+ {scheme}
+
+
+ )}
+
+
+ ))
+ return value
+}
+
+function segmentOf(name: string) {
+ return page.getByText(name, {exact: true})
+}
+
+function indicatorOf(): HTMLElement {
+ const indicator = document.querySelector('[data-scope="segment-group"][data-part="indicator"]')
+ if (!(indicator instanceof HTMLElement)) throw new Error('the segment group rendered no indicator')
+ return indicator
+}
+
+it('exposes the segments as one radio group with an accessible name', async () => {
+ mount({defaultValue: 'Auto'})
+
+ await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible()
+ await expect.element(page.getByRole('radio', {name: 'Auto'})).toBeChecked()
+ await expect.element(page.getByRole('radio', {name: 'Dark'})).not.toBeChecked()
+})
+
+it('moves the selection to the segment the reader clicks', async () => {
+ const value = mount({defaultValue: 'Auto'})
+
+ await segmentOf('Dark').click()
+
+ await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeChecked()
+ await expect.element(page.getByRole('radio', {name: 'Auto'})).not.toBeChecked()
+ expect(value()).toBe('Dark')
+})
+
+it('walks the selection along the group with the arrow keys', async () => {
+ const value = mount({defaultValue: 'Auto'})
+
+ await userEvent.tab()
+ await expect.element(page.getByRole('radio', {name: 'Auto'})).toHaveFocus()
+
+ await userEvent.keyboard('{ArrowRight}')
+
+ await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked()
+ await expect.element(page.getByRole('radio', {name: 'Light'})).toHaveFocus()
+ expect(value()).toBe('Light')
+})
+
+it('refuses a disabled segment', async () => {
+ const value = mount({defaultValue: 'Auto', disabled: 'Dark'})
+
+ await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeDisabled()
+
+ await segmentOf('Light').click()
+
+ await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked()
+ expect(value()).toBe('Light')
+})
+
+it('withholds the indicator until a segment is selected', async () => {
+ mount()
+
+ await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible()
+ expect(indicatorOf().hidden).toBe(true)
+
+ await segmentOf('Light').click()
+
+ await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked()
+ expect(indicatorOf().hidden).toBe(false)
+})