Combobox

A combobox is an autocomplete input and command palette with a list of suggestions. The Combobox is built using a composition of the <Popover /> and the <Command /> components.

PreviousNext
import { Component } from '@angular/core';
 
import { ZardComboboxComponent, type ZardComboboxOption } from '../combobox.component';
 
@Component({
  selector: 'zard-demo-combobox-default',
  standalone: true,
  imports: [ZardComboboxComponent],
  template: `
    <z-combobox
      [options]="frameworks"
      class="w-[200px]"
      [placeholder]="'Select framework...'"
      [searchPlaceholder]="'Search framework...'"
      [emptyText]="'No framework found.'"
      (zOnSelect)="onSelect($event)"
    />
  `,
})
export class ZardDemoComboboxDefaultComponent {
  frameworks: ZardComboboxOption[] = [
    { value: 'angular', label: 'Angular' },
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue.js' },
    { value: 'svelte', label: 'Svelte' },
    { value: 'ember', label: 'Ember.js' },
    { value: 'nextjs', label: 'Next.js' },
  ];
 
  onSelect(option: ZardComboboxOption) {
    console.log('Selected:', option);
  }
}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui add combobox
1

Add the component files

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

combobox.component.ts
combobox.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  ElementRef,
  EventEmitter,
  forwardRef,
  HostListener,
  input,
  Output,
  signal,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { type ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import type { ClassValue } from 'clsx';
 
import { ZardCommandOptionGroupComponent } from '../command/command-option-group.component';
import { ZardPopoverComponent, ZardPopoverDirective } from '../popover/popover.component';
import { ZardCommandComponent, type ZardCommandOption } from '../command/command.component';
import { ZardCommandOptionComponent } from '../command/command-option.component';
import { ZardCommandInputComponent } from '../command/command-input.component';
import { ZardCommandEmptyComponent } from '../command/command-empty.component';
import { ZardCommandListComponent } from '../command/command-list.component';
import { comboboxVariants, type ZardComboboxVariants } from './combobox.variants';
import { ZardButtonComponent } from '../button/button.component';
import { ZardEmptyComponent } from '../empty/empty.component';
import { ZardIconComponent } from '../icon/icon.component';
import { mergeClasses } from '../../shared/utils/utils';
import type { ZardIcon } from '../icon/icons';
 
export interface ZardComboboxOption {
  value: string;
  label: string;
  disabled?: boolean;
  icon?: ZardIcon;
}
 
export interface ZardComboboxGroup {
  label?: string;
  options: ZardComboboxOption[];
}
 
@Component({
  selector: 'z-combobox',
  exportAs: 'zCombobox',
  standalone: true,
  imports: [
    FormsModule,
    ZardButtonComponent,
    ZardCommandComponent,
    ZardCommandInputComponent,
    ZardCommandListComponent,
    ZardCommandEmptyComponent,
    ZardCommandOptionComponent,
    ZardCommandOptionGroupComponent,
    ZardPopoverDirective,
    ZardPopoverComponent,
    ZardEmptyComponent,
    ZardIconComponent,
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  template: `
    <button
      type="button"
      z-button
      zPopover
      [zContent]="popoverContent"
      [zType]="buttonVariant()"
      [class]="buttonClasses()"
      [disabled]="disabled()"
      role="combobox"
      [attr.aria-expanded]="open()"
      [attr.aria-haspopup]="'listbox'"
      [attr.aria-controls]="'combobox-listbox'"
      [attr.aria-label]="ariaLabel() || 'Select option'"
      [attr.aria-describedby]="ariaDescribedBy()"
      [attr.aria-autocomplete]="searchable() ? 'list' : 'none'"
      [attr.aria-activedescendant]="null"
      (zVisibleChange)="setOpen($event)"
      #popoverTrigger
    >
      <span class="flex-1 text-left truncate">
        {{ displayValue() ?? placeholder() }}
      </span>
      <z-icon zType="chevrons-up-down" class="ml-2 shrink-0 opacity-50" />
    </button>
 
    <ng-template #popoverContent>
      <z-popover [class]="popoverClasses()">
        <z-command class="min-h-auto" (zOnSelect)="handleSelect($event)" #commandRef>
          @if (searchable()) {
            <z-command-input [placeholder]="searchPlaceholder()" #commandInputRef />
          }
 
          <z-command-list id="combobox-listbox" role="listbox">
            @if (emptyText()) {
              <z-command-empty>
                <z-empty [zDescription]="emptyText()" />
              </z-command-empty>
            }
 
            @if (groups().length > 0) {
              @for (group of groups(); track group.label ?? $index) {
                @if (group.label) {
                  <z-command-option-group [zLabel]="group.label">
                    @for (option of group.options; track option.value) {
                      <z-command-option
                        [zValue]="option.value"
                        [zLabel]="option.label"
                        [zDisabled]="option.disabled ?? false"
                        [zIcon]="option.icon"
                        [attr.aria-selected]="option.value === getCurrentValue()"
                      >
                        {{ option.label }}
                        @if (option.value === getCurrentValue()) {
                          <z-icon zType="check" class="ml-auto" />
                        }
                      </z-command-option>
                    }
                  </z-command-option-group>
                } @else {
                  @for (option of group.options; track option.value) {
                    <z-command-option
                      [zValue]="option.value"
                      [zLabel]="option.label"
                      [zDisabled]="option.disabled ?? false"
                      [zIcon]="option.icon"
                      [attr.aria-selected]="option.value === getCurrentValue()"
                    >
                      {{ option.label }}
                      @if (option.value === getCurrentValue()) {
                        <z-icon zType="check" class="ml-auto" />
                      }
                    </z-command-option>
                  }
                }
              }
            } @else if (options().length > 0) {
              @for (option of options(); track option.value) {
                <z-command-option
                  [zValue]="option.value"
                  [zLabel]="option.label"
                  [zDisabled]="option.disabled ?? false"
                  [zIcon]="option.icon"
                  [attr.aria-selected]="option.value === getCurrentValue()"
                >
                  {{ option.label }}
                  @if (option.value === getCurrentValue()) {
                    <z-icon zType="check" class="ml-auto" />
                  }
                </z-command-option>
              }
            }
          </z-command-list>
        </z-command>
      </z-popover>
    </ng-template>
  `,
  host: {
    '[class]': 'classes()',
  },
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ZardComboboxComponent),
      multi: true,
    },
  ],
})
export class ZardComboboxComponent implements ControlValueAccessor {
  readonly class = input<ClassValue>('');
  readonly buttonVariant = input<'default' | 'outline' | 'secondary' | 'ghost'>('outline');
  readonly zWidth = input<ZardComboboxVariants['zWidth']>('default');
  readonly placeholder = input<string>('Select...');
  readonly searchPlaceholder = input<string>('Search...');
  readonly emptyText = input<string>('No results found.');
  readonly disabled = input<boolean>(false);
  readonly searchable = input<boolean>(true);
  readonly value = input<string | null>(null);
  readonly options = input<ZardComboboxOption[]>([]);
  readonly groups = input<ZardComboboxGroup[]>([]);
  readonly ariaLabel = input<string>('');
  readonly ariaDescribedBy = input<string>('');
 
  @Output() readonly zValueChange = new EventEmitter<string | null>();
  @Output() readonly zOnSelect = new EventEmitter<ZardComboboxOption>();
 
  readonly popoverDirective = viewChild.required('popoverTrigger', { read: ZardPopoverDirective });
  readonly buttonRef = viewChild.required('popoverTrigger', { read: ElementRef });
  readonly commandRef = viewChild('commandRef', { read: ZardCommandComponent });
  readonly commandInputRef = viewChild('commandInputRef', { read: ZardCommandInputComponent });
 
  protected readonly open = signal(false);
  protected readonly internalValue = signal<string | null>(null);
 
  protected readonly classes = computed(() =>
    mergeClasses(
      comboboxVariants({
        zWidth: this.zWidth(),
      }),
      this.class(),
    ),
  );
 
  protected readonly buttonClasses = computed(() => 'w-full justify-between');
 
  protected readonly popoverClasses = computed(() => {
    const widthClass = this.zWidth() === 'full' ? 'w-full' : 'w-[200px]';
    return `${widthClass} p-0`;
  });
 
  protected readonly getCurrentValue = computed(() => this.value() ?? this.internalValue());
 
  protected readonly displayValue = computed(() => {
    const currentValue = this.getCurrentValue();
    if (!currentValue) return null;
 
    // Search in groups first
    if (this.groups().length) {
      for (const group of this.groups()) {
        const option = group.options.find(opt => opt.value === currentValue);
        if (option) return option.label;
      }
    }
 
    // Then search in flat options
    const option = this.options().find(opt => opt.value === currentValue);
    return option?.label ?? null;
  });
 
  private onChange: (value: string | null) => void = () => {
    // ControlValueAccessor implementation
  };
  private onTouched: () => void = () => {
    // ControlValueAccessor implementation
  };
 
  setOpen(open: boolean) {
    this.open.set(open);
    if (open) {
      // Give time for the popover content to render and options to be detected
      setTimeout(() => {
        const commandRef = this.commandRef();
        if (commandRef) {
          // Refresh options to ensure they're detected
          commandRef.refreshOptions();
          // Focus on search input if searchable, otherwise on command component
          if (this.searchable()) {
            this.commandInputRef()?.focus();
          } else {
            commandRef.focus();
          }
        }
      }, 10);
    }
  }
 
  handleSelect(commandOption: ZardCommandOption) {
    const selectedValue = commandOption.value as string;
 
    // Toggle behavior - if same value is selected, clear it
    const newValue = selectedValue === this.getCurrentValue() ? null : selectedValue;
 
    this.internalValue.set(newValue);
    this.onChange(newValue);
    this.zValueChange.emit(newValue);
 
    // Emit the combobox option if we have a selection
    if (newValue) {
      let selectedOption: ZardComboboxOption | undefined;
 
      if (this.groups().length > 0) {
        for (const group of this.groups()) {
          selectedOption = group.options.find(opt => opt.value === newValue);
          if (selectedOption) break;
        }
      } else {
        selectedOption = this.options().find(opt => opt.value === newValue);
      }
 
      if (selectedOption) {
        this.zOnSelect.emit(selectedOption);
      }
    }
 
    // Close the popover
    this.popoverDirective().hide();
 
    // Return focus to the combobox button after selection
    this.buttonRef().nativeElement.focus();
  }
 
  @HostListener('keydown', ['$event'])
  onKeyDown(event: KeyboardEvent) {
    if (this.disabled()) return;
 
    // Handle different keyboard events based on combobox state
    if (this.open()) {
      // When popover is open
      switch (event.key) {
        case 'Escape':
          event.preventDefault();
          event.stopPropagation();
          this.popoverDirective().hide();
          this.buttonRef().nativeElement.focus();
          break;
 
        case 'Tab':
          // Allow tab to close and move to next element
          this.popoverDirective().hide();
          break;
 
        case 'ArrowDown':
        case 'ArrowUp':
        case 'Enter':
        case 'Home':
        case 'End':
        case 'PageUp':
        case 'PageDown':
          // Forward navigation to command component
          event.preventDefault();
          this.commandRef()?.onKeyDown(event);
          break;
      }
    } else {
      // When popover is closed
      switch (event.key) {
        case 'ArrowDown':
        case 'ArrowUp':
        case 'Enter':
        case ' ': // Space key
          event.preventDefault();
          this.popoverDirective().show();
          break;
 
        case 'Escape':
          // Clear selection if there's a value
          if (this.getCurrentValue()) {
            event.preventDefault();
            this.internalValue.set(null);
            this.onChange(null);
            this.zValueChange.emit(null);
          }
          break;
 
        default:
          // For searchable comboboxes, open and start typing
          if (this.searchable() && event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
            event.preventDefault();
            this.popoverDirective().show();
            // Let the command input handle the character after opening
            setTimeout(() => {
              const inputElement = this.commandInputRef();
              if (inputElement) {
                inputElement.focus();
                // Simulate the key press in the input
                const input = inputElement as unknown as {
                  searchInput?: { nativeElement: HTMLInputElement };
                  searchTerm: { set: (value: string) => void };
                  searchSubject: { next: (value: string) => void };
                };
                if (input.searchInput?.nativeElement) {
                  input.searchInput.nativeElement.value = event.key;
                  input.searchTerm.set(event.key);
                  input.searchSubject.next(event.key);
                }
              }
            }, 20);
          }
          break;
      }
    }
  }
 
  @HostListener('document:keydown', ['$event'])
  onDocumentKeyDown(event: KeyboardEvent) {
    // Close on Escape from anywhere when this combobox is open
    if (this.open() && event.key === 'Escape') {
      const target = event.target as Element;
      const buttonElement = this.buttonRef().nativeElement;
      // Only handle if not already handled by the component itself
      if (!buttonElement.contains(target)) {
        this.popoverDirective().hide();
        this.buttonRef().nativeElement.focus();
      }
    }
  }
 
  // ControlValueAccessor implementation
  writeValue(value: string | null): void {
    this.internalValue.set(value);
  }
 
  registerOnChange(fn: (value: string | null) => void): void {
    this.onChange = fn;
  }
 
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
 
  setDisabledState(): void {
    // The disabled state is handled by the disabled input
  }
}
 
combobox.variants.ts
combobox.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const comboboxVariants = cva('', {
  variants: {
    zWidth: {
      default: 'w-[200px]',
      sm: 'w-[150px]',
      md: 'w-[250px]',
      lg: 'w-[350px]',
      full: 'w-full',
    },
  },
  defaultVariants: {
    zWidth: 'default',
  },
});
 
export type ZardComboboxVariants = VariantProps<typeof comboboxVariants>;
 

Examples

default

import { Component } from '@angular/core';
 
import { ZardComboboxComponent, type ZardComboboxOption } from '../combobox.component';
 
@Component({
  selector: 'zard-demo-combobox-default',
  standalone: true,
  imports: [ZardComboboxComponent],
  template: `
    <z-combobox
      [options]="frameworks"
      class="w-[200px]"
      [placeholder]="'Select framework...'"
      [searchPlaceholder]="'Search framework...'"
      [emptyText]="'No framework found.'"
      (zOnSelect)="onSelect($event)"
    />
  `,
})
export class ZardDemoComboboxDefaultComponent {
  frameworks: ZardComboboxOption[] = [
    { value: 'angular', label: 'Angular' },
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue.js' },
    { value: 'svelte', label: 'Svelte' },
    { value: 'ember', label: 'Ember.js' },
    { value: 'nextjs', label: 'Next.js' },
  ];
 
  onSelect(option: ZardComboboxOption) {
    console.log('Selected:', option);
  }
}
 

grouped

import { Component } from '@angular/core';
 
import { ZardComboboxComponent, type ZardComboboxGroup, type ZardComboboxOption } from '../combobox.component';
 
@Component({
  selector: 'zard-demo-combobox-grouped',
  standalone: true,
  imports: [ZardComboboxComponent],
  template: `
    <z-combobox
      [groups]="techGroups"
      [placeholder]="'Select technology...'"
      [searchPlaceholder]="'Search technology...'"
      [emptyText]="'No technology found.'"
      (zOnSelect)="onSelect($event)"
    />
  `,
})
export class ZardDemoComboboxGroupedComponent {
  techGroups: ZardComboboxGroup[] = [
    {
      label: 'Frontend Frameworks',
      options: [
        { value: 'angular', label: 'Angular' },
        { value: 'react', label: 'React' },
        { value: 'vue', label: 'Vue.js' },
        { value: 'svelte', label: 'Svelte' },
      ],
    },
    {
      label: 'Backend Frameworks',
      options: [
        { value: 'nestjs', label: 'NestJS' },
        { value: 'express', label: 'Express' },
        { value: 'fastify', label: 'Fastify' },
        { value: 'koa', label: 'Koa' },
      ],
    },
    {
      label: 'Full-Stack Frameworks',
      options: [
        { value: 'nextjs', label: 'Next.js' },
        { value: 'nuxtjs', label: 'Nuxt.js' },
        { value: 'remix', label: 'Remix' },
        { value: 'sveltekit', label: 'SvelteKit' },
      ],
    },
  ];
 
  onSelect(option: ZardComboboxOption) {
    console.log('Selected:', option);
  }
}
 

disabled

import { Component } from '@angular/core';
 
import { ZardComboboxComponent, type ZardComboboxOption } from '../combobox.component';
 
@Component({
  selector: 'zard-demo-combobox-disabled',
  standalone: true,
  imports: [ZardComboboxComponent],
  template: `
    <div class="flex gap-4">
      <z-combobox [options]="frameworks" [placeholder]="'Disabled combobox'" [disabled]="true" />
 
      <z-combobox [options]="frameworksWithDisabled" [placeholder]="'Select framework...'" [searchPlaceholder]="'Search framework...'" [emptyText]="'No framework found.'" />
    </div>
  `,
})
export class ZardDemoComboboxDisabledComponent {
  frameworks: ZardComboboxOption[] = [
    { value: 'angular', label: 'Angular' },
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue.js' },
  ];
 
  frameworksWithDisabled: ZardComboboxOption[] = [
    { value: 'angular', label: 'Angular' },
    { value: 'react', label: 'React', disabled: true },
    { value: 'vue', label: 'Vue.js' },
    { value: 'svelte', label: 'Svelte', disabled: true },
    { value: 'ember', label: 'Ember.js' },
  ];
}
 

form

Current value: None
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardComboboxComponent, type ZardComboboxOption } from '../combobox.component';
 
@Component({
  selector: 'zard-demo-combobox-form',
  standalone: true,
  imports: [ReactiveFormsModule, ZardComboboxComponent, ZardButtonComponent],
  template: `
    <div class="flex flex-col gap-4">
      <z-combobox
        [options]="frameworks"
        [placeholder]="'Select framework...'"
        [searchPlaceholder]="'Search framework...'"
        [emptyText]="'No framework found.'"
        [formControl]="frameworkControl"
      />
 
      <div class="flex gap-2">
        <button z-button variant="outline" (click)="setValue()">Set to Vue.js</button>
        <button z-button variant="outline" (click)="clearValue()">Clear</button>
        <button z-button variant="outline" (click)="logValue()">Log Value</button>
      </div>
 
      <div class="text-sm text-muted-foreground">Current value: {{ frameworkControl.value ?? 'None' }}</div>
    </div>
  `,
})
export class ZardDemoComboboxFormComponent {
  frameworkControl = new FormControl<string | null>(null);
 
  frameworks: ZardComboboxOption[] = [
    { value: 'angular', label: 'Angular' },
    { value: 'react', label: 'React' },
    { value: 'vue', label: 'Vue.js' },
    { value: 'svelte', label: 'Svelte' },
    { value: 'ember', label: 'Ember.js' },
  ];
 
  setValue() {
    this.frameworkControl.setValue('vue');
  }
 
  clearValue() {
    this.frameworkControl.setValue(null);
  }
 
  logValue() {
    console.log('Form Control Value:', this.frameworkControl.value);
  }
}
 

API

Property Type Default Description
class ClassValue '' Additional CSS classes
buttonVariant 'default' | 'outline' | 'secondary' | 'ghost' 'outline' Button variant style
zWidth 'default' | 'sm' | 'md' | 'lg' | 'full' 'default' Width of the combobox
placeholder string 'Select...' Placeholder text when no value is selected
searchPlaceholder string 'Search...' Placeholder for the search input
emptyText string 'No results found.' Text shown when no options match the search
disabled boolean false Whether the combobox is disabled
searchable boolean true Whether to show the search input
value string | null null The selected value
options ZardComboboxOption[] [] Array of options (for flat list)
groups ZardComboboxGroup[] [] Array of grouped options
ariaLabel string '' ARIA label for accessibility
ariaDescribedBy string '' ARIA described-by for accessibility

Outputs

Event Type Description
zValueChange EventEmitter<string | null> Emitted when the value changes
zOnSelect EventEmitter<ZardComboboxOption> Emitted when an option is selected