Segmented
A single-choice control with a sliding selection indicator.
A single-choice control. iOS renders the inset grouped pill with a sliding thumb; Android renders an MD3 connected button group whose selected segment fills with the secondary container and shows a checkmark. Both platforms share the same markup — the cascade decides the look.
Installation
npx touchcn add segmented
Usage
<tcn-segmented [(value)]="range">
<tcn-segment value="day" label="Day" />
<tcn-segment value="week" label="Week" />
<tcn-segment value="month" label="Month" />
</tcn-segmented>import { TcnSegmented, TcnSegment } from '@/components/ui/segmented';
<TcnSegmented value={range} onValueChange={setRange}>
<TcnSegment value="day">Day</TcnSegment>
<TcnSegment value="week">Week</TcnSegment>
<TcnSegment value="month">Month</TcnSegment>
</TcnSegmented><script setup lang="ts">
import { TcnSegmented, TcnSegment } from '@/components/ui/segmented';
</script>
<template>
<TcnSegmented v-model="range">
<TcnSegment value="day">Day</TcnSegment>
<TcnSegment value="week">Week</TcnSegment>
<TcnSegment value="month">Month</TcnSegment>
</TcnSegmented>
</template>The control carries role="radiogroup" and each segment role="radio", with roving tabindex and arrow-key navigation — the segmented-control accessibility convention.
Props
Segmented
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
'' |
Selected segment value. |
disabled |
boolean |
false |
Disable the whole control. |
Segment
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
— | Identifies the segment (required). |
label |
string |
'' |
Segment text (Angular; React uses children). |
disabled |
boolean |
false |
Disable this segment. |
Source · Angular
export * from './tcn-segmented';import {
booleanAttribute,
Component,
computed,
contentChildren,
ElementRef,
input,
model,
viewChildren,
} from '@angular/core';
import { TcnRadioKeyNavDirective } from '@touchcn/angular/radio';
import { TcnTabThumbDirective } from '@touchcn/angular/tabs';
/**
* Segmented control — a single-choice picker with a value model. iOS renders the
* inset grouped pill with a sliding selection thumb; MD3 renders a connected
* button group (outlined segments, the selected one filled with the secondary
* container and marked by a checkmark). Both platforms share this markup; the
* cascade shows the thumb on iOS and the fill/check on MD.
*
* Behaviour is composed entirely from existing engine primitives: the sliding
* thumb reuses `TcnTabThumb` measurement (as Tabs does) and roving arrow-key
* navigation reuses `TcnRadioKeyNav` — the group carries `role="radiogroup"`
* and each segment `role="radio"`, per segmented-control conventions.
*/
@Component({
selector: 'tcn-segmented',
imports: [TcnTabThumbDirective, TcnRadioKeyNavDirective],
template: `
<div
#pill="tcnTabThumb"
tcnTabThumb
[active]="activeEl()"
tcnRadioKeyNav
[items]="segmentEls()"
(activate)="onActivate($event)"
role="radiogroup"
class="tcn-segmented"
[attr.aria-disabled]="disabled() || null"
>
<span
class="tcn-segmented-thumb"
aria-hidden="true"
[class.tcn-segmented-thumb--static]="!pill.animate()"
[style.opacity]="pill.thumb() ? null : 0"
[style.width.px]="pill.thumb()?.width"
[style.transform]="pill.thumb() ? 'translateX(' + pill.thumb()!.left + 'px)' : null"
></span>
@for (segment of segments(); track segment.value()) {
<button
#segBtn
type="button"
role="radio"
class="tcn-segment"
[attr.data-active]="segment.value() === activeValue()"
[attr.aria-checked]="segment.value() === activeValue()"
[attr.tabindex]="segment.value() === activeValue() ? 0 : -1"
[disabled]="disabled() || segment.disabled()"
(click)="select(segment.value())"
>
<span class="tcn-segment-check" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M5 12l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="tcn-segment-label">{{ segment.label() }}</span>
</button>
}
</div>
`,
host: { class: 'block' },
})
export class TcnSegmented {
readonly value = model('');
readonly disabled = input(false, { transform: booleanAttribute });
protected readonly segments = contentChildren(TcnSegment);
private readonly segmentButtons = viewChildren<ElementRef<HTMLButtonElement>>('segBtn');
protected readonly segmentEls = computed(() => this.segmentButtons().map((ref) => ref.nativeElement));
/** The effective active value — the model, or the first segment until one is set. */
readonly activeValue = computed(() => this.value() || this.segments()[0]?.value() || '');
protected readonly activeEl = computed(() => {
const index = this.segments().findIndex((segment) => segment.value() === this.activeValue());
return this.segmentButtons()[index]?.nativeElement ?? null;
});
select(value: string): void {
if (!this.disabled()) {
this.value.set(value);
}
}
protected onActivate(element: HTMLElement): void {
const index = this.segmentEls().indexOf(element as HTMLButtonElement);
const segment = this.segments()[index];
if (segment) {
this.select(segment.value());
}
}
}
/** A single choice within a `tcn-segmented`; `label` names the segment. */
@Component({
selector: 'tcn-segment',
template: '',
})
export class TcnSegment {
readonly value = input.required<string>();
readonly label = input('');
readonly disabled = input(false, { transform: booleanAttribute });
}Source · React
export * from './tcn-segmented';import { createContext, useContext } from 'react';
import type { Ref, ReactNode } from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { useTabThumb } from '@touchcn/react';
const SegmentedContext = createContext<string | undefined>(undefined);
export interface TcnSegmentedProps {
value: string;
onValueChange(value: string): void;
disabled?: boolean;
children?: ReactNode;
}
/**
* Segmented control — a single-choice picker. iOS renders the inset grouped pill
* with a sliding selection thumb; MD3 renders a connected button group (outlined
* segments, the selected one filled with the secondary container and marked by a
* checkmark). Both platforms share this markup; the cascade shows the thumb on
* iOS and the fill/check on MD.
*
* Radix `RadioGroup` supplies the `role="radiogroup"` container, roving
* `tabindex`, arrow-key navigation and single-selection value model (the
* behaviour the Angular engine directive re-implements), and the sliding thumb
* reuses the engine `useTabThumb` measurement — keeping the emitted `tcn-*`
* classes and `data-active` attributes identical to the Angular component.
*/
export function TcnSegmented({ value, onValueChange, disabled, children }: TcnSegmentedProps) {
const { pillRef, thumbStyle, animate } = useTabThumb(value, '.tcn-segment[data-active="true"]');
return (
<RadioGroupPrimitive.Root
ref={pillRef as Ref<HTMLDivElement>}
className="tcn-segmented"
value={value}
onValueChange={onValueChange}
disabled={disabled}
>
<SegmentedContext.Provider value={value}>
<span
className={`tcn-segmented-thumb${animate ? '' : ' tcn-segmented-thumb--static'}`}
aria-hidden="true"
style={thumbStyle}
/>
{children}
</SegmentedContext.Provider>
</RadioGroupPrimitive.Root>
);
}
export interface TcnSegmentProps {
value: string;
disabled?: boolean;
children?: ReactNode;
}
export function TcnSegment({ value, disabled, children }: TcnSegmentProps) {
const active = useContext(SegmentedContext) === value;
return (
<RadioGroupPrimitive.Item value={value} disabled={disabled} className="tcn-segment" data-active={active}>
<span className="tcn-segment-check" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M5 12l4 4L19 7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="tcn-segment-label">{children}</span>
</RadioGroupPrimitive.Item>
);
}Source · Vue
<script setup lang="ts">
import { computed, inject } from 'vue';
import { RadioGroupItem } from 'reka-ui';
import { TcnSegmentedKey } from './context';
const props = defineProps<{ value: string; disabled?: boolean }>();
const selected = inject(TcnSegmentedKey);
const active = computed(() => selected?.value === props.value);
</script>
<template>
<RadioGroupItem :value="value" :disabled="disabled" class="tcn-segment" :data-active="active">
<span class="tcn-segment-check" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M5 12l4 4L19 7"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="tcn-segment-label"><slot /></span>
</RadioGroupItem>
</template><script setup lang="ts">
import { computed, provide } from 'vue';
import { RadioGroupRoot } from 'reka-ui';
import { useTabThumb } from '@touchcn/vue';
import { TcnSegmentedKey } from './context';
/**
* Segmented control — a single-choice picker. iOS renders the inset grouped pill
* with a sliding selection thumb; MD3 renders a connected button group (outlined
* segments, the selected one filled with the secondary container and marked by a
* checkmark). Both platforms share this markup; the cascade shows the thumb on
* iOS and the fill/check on MD.
*
* reka-ui `RadioGroupRoot` supplies the `role="radiogroup"` container, roving
* `tabindex`, arrow-key navigation and single-selection value model (the
* behaviour the Angular engine directive re-implements), and the sliding thumb
* reuses the engine `useTabThumb` measurement — keeping the emitted `tcn-*`
* classes and `data-active` attributes identical across frameworks. Two-way
* bound with `v-model`.
*/
defineProps<{ disabled?: boolean }>();
const model = defineModel<string>({ required: true });
const { setPill, thumbStyle, animate } = useTabThumb(
() => model.value,
'.tcn-segment[data-active="true"]',
);
provide(
TcnSegmentedKey,
computed(() => model.value),
);
</script>
<template>
<RadioGroupRoot :ref="setPill" v-model="model" :disabled="disabled" class="tcn-segmented">
<span
:class="['tcn-segmented-thumb', !animate && 'tcn-segmented-thumb--static']"
aria-hidden="true"
:style="thumbStyle"
/>
<slot />
</RadioGroupRoot>
</template>import type { ComputedRef, InjectionKey } from 'vue';
/** Injection key carrying the selected value from `TcnSegmented` to each `TcnSegment`. */
export const TcnSegmentedKey: InjectionKey<ComputedRef<string | undefined>> = Symbol('tcn-segmented');export { default as TcnSegmented } from './TcnSegmented.vue';
export { default as TcnSegment } from './TcnSegment.vue';