Segmented Control

A set of two or more segments, each of which functions as a mutually exclusive button. Based on shadcn/ui's Tabs component pattern, providing a clean way to create toggle controls with single selection.

PreviousNext
import { ChangeDetectionStrategy, Component } from '@angular/core';
 
import { ZardSegmentedComponent } from '../segmented.component';
 
@Component({
  selector: 'zard-demo-segmented-default',
  standalone: true,
  imports: [ZardSegmentedComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: ` <z-segmented [zOptions]="options" zDefaultValue="all" (zChange)="onSelectionChange($event)"> </z-segmented> `,
})
export class ZardDemoSegmentedDefaultComponent {
  options = [
    { value: 'all', label: 'All' },
    { value: 'unread', label: 'Unread' },
    { value: 'archived', label: 'Archived' },
  ];
 
  onSelectionChange(value: string) {
    console.log('Selected:', value);
  }
}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui add segmented
1

Add the component files

Create the component directory structure and add the following files to your project.

segmented.component.ts
segmented.component.ts
import { ChangeDetectionStrategy, Component, computed, contentChildren, effect, forwardRef, input, type OnInit, output, signal, ViewEncapsulation } from '@angular/core';
import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 
import { mergeClasses } from '../../shared/utils/utils';
import { segmentedItemVariants, segmentedVariants, type ZardSegmentedVariants } from './segmented.variants';
 
import type { ClassValue } from 'clsx';
 
export interface SegmentedOption {
  value: string;
  label: string;
  disabled?: boolean;
}
@Component({
  selector: 'z-segmented-item',
  standalone: true,
  template: `<ng-content></ng-content>`,
  encapsulation: ViewEncapsulation.None,
})
export class ZardSegmentedItemComponent {
  readonly value = input.required<string>();
  readonly label = input.required<string>();
  readonly disabled = input(false);
}
 
@Component({
  selector: 'z-segmented',
  exportAs: 'zSegmented',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    <div [class]="classes()" role="tablist" [attr.aria-label]="zAriaLabel()">
      @if (zOptions().length > 0) {
        @for (option of zOptions(); track option.value) {
          <button
            type="button"
            role="tab"
            [class]="getItemClasses(option.value)"
            [disabled]="option.disabled || zDisabled()"
            [attr.aria-selected]="isSelected(option.value)"
            [attr.aria-controls]="option.value + '-panel'"
            [attr.id]="option.value + '-tab'"
            (click)="selectOption(option.value)"
          >
            {{ option.label }}
          </button>
        }
      } @else {
        @for (item of items(); track item.value()) {
          <button
            type="button"
            role="tab"
            [class]="getItemClasses(item.value())"
            [disabled]="item.disabled() || zDisabled()"
            [attr.aria-selected]="isSelected(item.value())"
            [attr.aria-controls]="item.value() + '-panel'"
            [attr.id]="item.value() + '-tab'"
            (click)="selectOption(item.value())"
          >
            {{ item.label() }}
          </button>
        }
      }
    </div>
  `,
  host: {
    '[class]': 'wrapperClasses()',
  },
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ZardSegmentedComponent),
      multi: true,
    },
  ],
})
export class ZardSegmentedComponent implements ControlValueAccessor, OnInit {
  private readonly itemComponents = contentChildren(ZardSegmentedItemComponent);
 
  readonly class = input<ClassValue>('');
  readonly zSize = input<ZardSegmentedVariants['zSize']>('default');
  readonly zOptions = input<SegmentedOption[]>([]);
  readonly zDefaultValue = input<string>('');
  readonly zDisabled = input(false);
  readonly zAriaLabel = input<string>('Segmented control');
 
  readonly zChange = output<string>();
 
  protected readonly selectedValue = signal<string>('');
  protected readonly items = signal<readonly ZardSegmentedItemComponent[]>([]);
 
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private onChange: (value: string) => void = () => {};
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private onTouched = () => {};
 
  constructor() {
    effect(() => {
      this.items.set(this.itemComponents());
    });
  }
 
  ngOnInit() {
    // Initialize with default value
    if (this.zDefaultValue()) {
      this.selectedValue.set(this.zDefaultValue());
    }
  }
 
  protected readonly classes = computed(() => mergeClasses(segmentedVariants({ zSize: this.zSize() }), this.class()));
 
  protected readonly wrapperClasses = computed(() => 'inline-block');
 
  protected getItemClasses(value: string): string {
    return segmentedItemVariants({
      zSize: this.zSize(),
      isActive: this.isSelected(value),
    });
  }
 
  protected isSelected(value: string): boolean {
    return this.selectedValue() === value;
  }
 
  protected selectOption(value: string) {
    if (this.zDisabled()) return;
 
    const option = this.zOptions().find(opt => opt.value === value);
    const item = this.items().find(item => item.value() === value);
 
    if (option?.disabled || item?.disabled()) return;
 
    this.selectedValue.set(value);
    this.onChange(value);
    this.onTouched();
    this.zChange.emit(value);
  }
 
  // ControlValueAccessor implementation
  writeValue(value: string): void {
    this.selectedValue.set(value ?? '');
  }
 
  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }
 
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
 
  setDisabledState(_isDisabled: boolean): void {
    // Handled by zDisabled input
  }
}
 
segmented.variants.ts
segmented.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const segmentedVariants = cva('inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', {
  variants: {
    zSize: {
      sm: 'h-9 text-xs',
      default: 'h-10 text-sm',
      lg: 'h-12 text-base',
    },
  },
  defaultVariants: {
    zSize: 'default',
  },
});
 
export const segmentedItemVariants = cva(
  'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      zSize: {
        sm: 'px-2 py-1 text-xs',
        default: 'px-3 py-1.5 text-sm',
        lg: 'px-4 py-2 text-base',
      },
      isActive: {
        true: 'bg-background text-foreground shadow-sm',
        false: 'hover:bg-muted/50',
      },
    },
    defaultVariants: {
      zSize: 'default',
      isActive: false,
    },
  },
);
 
export type ZardSegmentedVariants = VariantProps<typeof segmentedVariants>;
export type ZardSegmentedItemVariants = VariantProps<typeof segmentedItemVariants>;
 

Examples

default

import { ChangeDetectionStrategy, Component } from '@angular/core';
 
import { ZardSegmentedComponent } from '../segmented.component';
 
@Component({
  selector: 'zard-demo-segmented-default',
  standalone: true,
  imports: [ZardSegmentedComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: ` <z-segmented [zOptions]="options" zDefaultValue="all" (zChange)="onSelectionChange($event)"> </z-segmented> `,
})
export class ZardDemoSegmentedDefaultComponent {
  options = [
    { value: 'all', label: 'All' },
    { value: 'unread', label: 'Unread' },
    { value: 'archived', label: 'Archived' },
  ];
 
  onSelectionChange(value: string) {
    console.log('Selected:', value);
  }
}
 

sizes

import { ChangeDetectionStrategy, Component } from '@angular/core';
 
import { ZardSegmentedComponent } from '../segmented.component';
 
@Component({
  selector: 'zard-demo-segmented-sizes',
  standalone: true,
  imports: [ZardSegmentedComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="space-y-4">
      <div>
        <label class="block text-sm font-medium mb-2">Small</label>
        <z-segmented zSize="sm" [zOptions]="options" zDefaultValue="tab1"> </z-segmented>
      </div>
 
      <div>
        <label class="block text-sm font-medium mb-2">Default</label>
        <z-segmented [zOptions]="options" zDefaultValue="tab1"> </z-segmented>
      </div>
 
      <div>
        <label class="block text-sm font-medium mb-2">Large</label>
        <z-segmented zSize="lg" [zOptions]="options" zDefaultValue="tab1"> </z-segmented>
      </div>
    </div>
  `,
})
export class ZardDemoSegmentedSizesComponent {
  options = [
    { value: 'tab1', label: 'Tab 1' },
    { value: 'tab2', label: 'Tab 2' },
    { value: 'tab3', label: 'Tab 3' },
  ];
}
 

disabled

import { ChangeDetectionStrategy, Component } from '@angular/core';
 
import { ZardSegmentedComponent } from '../segmented.component';
 
@Component({
  selector: 'zard-demo-segmented-disabled',
  standalone: true,
  imports: [ZardSegmentedComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="space-y-4">
      <div>
        <label class="block text-sm font-medium mb-2">With disabled options</label>
        <z-segmented [zOptions]="optionsWithDisabled" zDefaultValue="enabled1"> </z-segmented>
      </div>
 
      <div>
        <label class="block text-sm font-medium mb-2">Entire component disabled</label>
        <z-segmented [zOptions]="options" zDefaultValue="tab1" [zDisabled]="true"> </z-segmented>
      </div>
    </div>
  `,
})
export class ZardDemoSegmentedDisabledComponent {
  options = [
    { value: 'tab1', label: 'Tab 1' },
    { value: 'tab2', label: 'Tab 2' },
    { value: 'tab3', label: 'Tab 3' },
  ];
 
  optionsWithDisabled = [
    { value: 'enabled1', label: 'Enabled' },
    { value: 'disabled1', label: 'Disabled', disabled: true },
    { value: 'enabled2', label: 'Enabled' },
    { value: 'disabled2', label: 'Disabled', disabled: true },
  ];
}
 

API

Inputs

Name Type Default Description
class ClassValue '' Additional CSS classes to apply
zSize 'sm' | 'default' | 'lg' 'default' Size of the segmented control
zOptions SegmentedOption[] [] Array of options to display
zDefaultValue string '' Default selected value
zDisabled boolean false Whether the entire control is disabled
zAriaLabel string 'Segmented control' ARIA label for accessibility

Outputs

Name Type Description
zChange string Emitted when selection changes