Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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';

Last updated on July 24, 2026

Was this page helpful?