Tab

A set of layered sections of content—known as tab panels—that are displayed one at a time.

PreviousNext

Is the default tab component

import { Component } from '@angular/core';
 
import { ZardTabComponent, ZardTabGroupComponent } from '../tabs.component';
 
@Component({
  selector: 'z-demo-tabs-default',
  standalone: true,
  imports: [ZardTabComponent, ZardTabGroupComponent],
  template: `
    <div class="h-[300px] w-full">
      <z-tab-group>
        <z-tab label="First">
          <p>Is the default tab component</p>
        </z-tab>
        <z-tab label="Second">
          <p>Content of the second tab</p>
        </z-tab>
        <z-tab label="Third">
          <p>Content of the third tab</p>
        </z-tab>
        <z-tab label="Fourth">
          <p>Content of the fourth tab</p>
        </z-tab>
        <z-tab label="Fifth">
          <p>Content of the fifth tab</p>
        </z-tab>
        <z-tab label="Sixth">
          <p>Content of the sixth tab</p>
        </z-tab>
      </z-tab-group>
    </div>
  `,
})
export class ZardDemoTabsDefaultComponent {}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui add tabs
1

Add the component files

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

tabs.component.ts
tabs.component.ts
import {
  afterNextRender,
  type AfterViewInit,
  ChangeDetectionStrategy,
  Component,
  computed,
  contentChildren,
  DestroyRef,
  DOCUMENT,
  type ElementRef,
  inject,
  Injector,
  input,
  output,
  runInInjectionContext,
  signal,
  type TemplateRef,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { debounceTime, fromEvent, merge, map, distinctUntilChanged } from 'rxjs';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
 
import { tabButtonVariants, tabContainerVariants, tabNavVariants, type ZardTabVariants } from './tabs.variants';
import { ZardButtonComponent } from '../button/button.component';
import { ZardIconComponent } from '../icon/icon.component';
 
export type zPosition = 'top' | 'bottom' | 'left' | 'right';
export type zAlign = 'center' | 'start' | 'end';
 
@Component({
  selector: 'z-tab',
  standalone: true,
  imports: [],
  template: `
    <ng-template #content>
      <ng-content></ng-content>
    </ng-template>
  `,
  encapsulation: ViewEncapsulation.None,
})
export class ZardTabComponent {
  label = input.required<string>();
  readonly contentTemplate = viewChild.required<TemplateRef<unknown>>('content');
}
 
@Component({
  selector: 'z-tab-group',
  standalone: true,
  imports: [CommonModule, ZardButtonComponent, ZardIconComponent],
  host: { '[class]': 'containerClasses()' },
  template: `
    @if (navBeforeContent()) {
      <ng-container [ngTemplateOutlet]="navigationBlock"></ng-container>
    }
 
    <div class="tab-content flex-1">
      @for (tab of tabs(); track $index; let index = $index) {
        <div
          role="tabpanel"
          [attr.id]="'tabpanel-' + index"
          [attr.aria-labelledby]="'tab-' + index"
          [attr.tabindex]="0"
          [hidden]="activeTabIndex() !== index"
          class="outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
        >
          <ng-container [ngTemplateOutlet]="tab.contentTemplate()"></ng-container>
        </div>
      }
    </div>
 
    @if (!navBeforeContent()) {
      <ng-container [ngTemplateOutlet]="navigationBlock"></ng-container>
    }
 
    <ng-template #navigationBlock>
      @let horizontal = isHorizontal();
 
      <div [class]="navGridClasses()">
        @if (showArrow()) {
          @if (horizontal) {
            <button class="scroll-btn scroll-left pr-4 cursor-pointer" [class]="zTabsPosition() === 'top' ? 'mb-4' : 'mt-4'" (click)="scrollNav('left')">
              <z-icon zType="chevron-left" />
            </button>
          } @else {
            <button class="scroll-btn scroll-up pb-4 cursor-pointer" [class]="zTabsPosition() === 'left' ? 'mr-4' : 'ml-4'" (click)="scrollNav('up')">
              <z-icon zType="chevron-up" />
            </button>
          }
        }
 
        <nav [ngClass]="navClasses()" #tabNav role="tablist" [attr.aria-orientation]="horizontal ? 'horizontal' : 'vertical'">
          @for (tab of tabs(); track $index; let index = $index) {
            <button
              z-button
              zType="ghost"
              role="tab"
              [attr.id]="'tab-' + index"
              [attr.aria-selected]="activeTabIndex() === index"
              [attr.tabindex]="activeTabIndex() === index ? 0 : -1"
              [attr.aria-controls]="'tabpanel-' + index"
              (click)="setActiveTab(index)"
              [ngClass]="buttonClassesSignal()[index]"
            >
              {{ tab.label() }}
            </button>
          }
        </nav>
 
        @if (showArrow()) {
          @if (horizontal) {
            <button class="scroll-btn scroll-right pl-4 cursor-pointer" [class]="zTabsPosition() === 'top' ? 'mb-4' : 'mt-4'" (click)="scrollNav('right')">
              <z-icon zType="chevron-right" />
            </button>
          } @else {
            <button class="scroll-btn scroll-down pt-4 cursor-pointer" [class]="zTabsPosition() === 'left' ? 'mr-4' : 'ml-4'" (click)="scrollNav('down')">
              <z-icon zType="chevron-down" />
            </button>
          }
        }
      </div>
    </ng-template>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  styles: [
    `
      .nav-tab-scroll {
        -webkit-overflow-scrolling: touch;
        scroll-behavior: smooth;
        &::-webkit-scrollbar-thumb {
          background-color: rgba(209, 209, 209, 0.2);
          border-radius: 2px;
        }
        &::-webkit-scrollbar {
          height: 4px;
          width: 4px;
        }
        &::-webkit-scrollbar-button {
          display: none;
        }
      }
    `,
  ],
})
export class ZardTabGroupComponent implements AfterViewInit {
  private readonly tabComponents = contentChildren(ZardTabComponent, { descendants: true });
  private readonly tabsContainer = viewChild.required<ElementRef>('tabNav');
  private readonly destroyRef = inject(DestroyRef);
  private readonly injector = inject(Injector);
  private readonly window = inject(DOCUMENT).defaultView;
 
  protected readonly tabs = computed(() => this.tabComponents());
  protected readonly activeTabIndex = signal<number>(0);
  protected readonly scrollPresent = signal<boolean>(false);
 
  protected readonly zOnTabChange = output<{
    index: number;
    label: string;
    tab: ZardTabComponent;
  }>();
  protected readonly zDeselect = output<{
    index: number;
    label: string;
    tab: ZardTabComponent;
  }>();
 
  public readonly zTabsPosition = input<ZardTabVariants['zPosition']>('top');
  public readonly zActivePosition = input<ZardTabVariants['zActivePosition']>('bottom');
  public readonly zShowArrow = input(true);
  public readonly zScrollAmount = input(100);
  public readonly zAlignTabs = input<zAlign>('start');
  // Preserve consumer classes on host
  public readonly class = input<string>('');
 
  protected readonly showArrow = computed(() => this.zShowArrow() && this.scrollPresent());
 
  ngAfterViewInit(): void {
    // default tab selection
    if (this.tabs().length) {
      this.setActiveTab(0);
    }
 
    runInInjectionContext(this.injector, () => {
      const observeInputs$ = merge(toObservable(this.zShowArrow), toObservable(this.tabs), toObservable(this.zTabsPosition));
 
      // Re-observe whenever #tabNav reference changes (e.g., when placement toggles)
      let observedEl: HTMLElement | null = null;
      const tabNavEl$ = toObservable(this.tabsContainer).pipe(
        map(ref => ref.nativeElement as HTMLElement),
        distinctUntilChanged(),
      );
 
      afterNextRender(() => {
        // SSR/browser guard
        if (!this.window || typeof ResizeObserver === 'undefined') return;
 
        const resizeObserver = new ResizeObserver(() => this.setScrollState());
 
        tabNavEl$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(el => {
          if (observedEl) resizeObserver.unobserve(observedEl);
          observedEl = el;
          resizeObserver.observe(el);
          this.setScrollState();
        });
 
        merge(observeInputs$, fromEvent(this.window, 'resize'))
          .pipe(debounceTime(10), takeUntilDestroyed(this.destroyRef))
          .subscribe(() => this.setScrollState());
 
        this.destroyRef.onDestroy(() => resizeObserver.disconnect());
      });
    });
  }
 
  private setScrollState(): void {
    if (this.hasScroll() !== this.scrollPresent()) {
      this.scrollPresent.set(this.hasScroll());
    }
  }
 
  private hasScroll(): boolean {
    const navElement: HTMLElement = this.tabsContainer().nativeElement;
    if (this.zShowArrow()) {
      return navElement.scrollWidth > navElement.clientWidth || navElement.scrollHeight > navElement.clientHeight;
    }
    return false;
  }
 
  protected setActiveTab(index: number) {
    const currentTab = this.tabs()[this.activeTabIndex()];
    if (index !== this.activeTabIndex()) {
      this.zDeselect.emit({
        index: this.activeTabIndex(),
        label: currentTab.label(),
        tab: currentTab,
      });
    }
 
    this.activeTabIndex.set(index);
    const activeTabComponent = this.tabs()[index];
    if (activeTabComponent) {
      this.zOnTabChange.emit({
        index,
        label: activeTabComponent.label(),
        tab: activeTabComponent,
      });
    }
  }
 
  protected readonly navBeforeContent = computed(() => {
    const position = this.zTabsPosition();
    return position === 'top' || position === 'left';
  });
 
  protected readonly isHorizontal = computed(() => {
    const position = this.zTabsPosition();
    return position === 'top' || position === 'bottom';
  });
 
  protected readonly navGridClasses = computed(() => {
    const gridLayout = this.isHorizontal() ? 'grid-cols-[25px_1fr_25px]' : 'grid-rows-[25px_1fr_25px]';
    if (this.showArrow()) {
      return twMerge(clsx('grid', gridLayout));
    }
    return 'grid';
  });
 
  protected readonly containerClasses = computed(() => twMerge(tabContainerVariants({ zPosition: this.zTabsPosition() }), this.class()));
 
  protected readonly navClasses = computed(() => tabNavVariants({ zPosition: this.zTabsPosition(), zAlignTabs: this.showArrow() ? 'start' : this.zAlignTabs() }));
 
  protected readonly buttonClassesSignal = computed(() => {
    const activeIndex = this.activeTabIndex();
    const position = this.zActivePosition();
    return this.tabs().map((_, index) => {
      const isActive = activeIndex === index;
      return tabButtonVariants({ zActivePosition: position, isActive });
    });
  });
 
  protected scrollNav(direction: 'left' | 'right' | 'up' | 'down') {
    const container = this.tabsContainer().nativeElement;
    const scrollAmount = this.zScrollAmount();
    if (direction === 'left') {
      container.scrollLeft -= scrollAmount;
    } else if (direction === 'right') {
      container.scrollLeft += scrollAmount;
    } else if (direction === 'up') {
      container.scrollTop -= scrollAmount;
    } else if (direction === 'down') {
      container.scrollTop += scrollAmount;
    }
  }
 
  public selectTabByIndex(index: number): void {
    if (index >= 0 && index < this.tabs().length) {
      this.setActiveTab(index);
    } else {
      console.warn(`Index ${index} outside the range of available tabs.`);
    }
  }
}
 
tabs.variants.ts
tabs.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
import type { zAlign } from './tabs.component';
 
export const tabContainerVariants = cva('flex', {
  variants: {
    zPosition: {
      top: 'flex-col',
      bottom: 'flex-col',
      left: 'flex-row',
      right: 'flex-row',
    },
  },
  defaultVariants: {
    zPosition: 'top',
  },
});
 
export const tabNavVariants = cva('flex gap-4 overflow-auto scroll nav-tab-scroll', {
  variants: {
    zPosition: {
      top: 'flex-row border-b mb-4',
      bottom: 'flex-row border-t mt-4',
      left: 'flex-col border-r mr-4 min-h-0',
      right: 'flex-col border-l ml-4 min-h-0',
    },
    zAlignTabs: {
      start: 'justify-start',
      center: 'justify-center',
      end: 'justify-end',
    },
  },
  defaultVariants: {
    zPosition: 'top',
    zAlignTabs: 'start',
  },
});
 
export const tabButtonVariants = cva('hover:bg-transparent rounded-none flex-shrink-0', {
  variants: {
    zActivePosition: {
      top: '',
      bottom: '',
      left: '',
      right: '',
    },
    isActive: {
      true: '',
      false: '',
    },
  },
  compoundVariants: [
    {
      zActivePosition: 'top',
      isActive: true,
      class: 'border-t-2 border-t-primary',
    },
    {
      zActivePosition: 'bottom',
      isActive: true,
      class: 'border-b-2 border-b-primary',
    },
    {
      zActivePosition: 'left',
      isActive: true,
      class: 'border-l-2 border-l-primary',
    },
    {
      zActivePosition: 'right',
      isActive: true,
      class: 'border-r-2 border-r-primary',
    },
  ],
  defaultVariants: {
    zActivePosition: 'bottom',
    isActive: false,
  },
});
 
export type ZardTabVariants = VariantProps<typeof tabContainerVariants> & VariantProps<typeof tabNavVariants> & VariantProps<typeof tabButtonVariants> & { zAlignTabs: zAlign };
 

Examples

default

Is the default tab component

import { Component } from '@angular/core';
 
import { ZardTabComponent, ZardTabGroupComponent } from '../tabs.component';
 
@Component({
  selector: 'z-demo-tabs-default',
  standalone: true,
  imports: [ZardTabComponent, ZardTabGroupComponent],
  template: `
    <div class="h-[300px] w-full">
      <z-tab-group>
        <z-tab label="First">
          <p>Is the default tab component</p>
        </z-tab>
        <z-tab label="Second">
          <p>Content of the second tab</p>
        </z-tab>
        <z-tab label="Third">
          <p>Content of the third tab</p>
        </z-tab>
        <z-tab label="Fourth">
          <p>Content of the fourth tab</p>
        </z-tab>
        <z-tab label="Fifth">
          <p>Content of the fifth tab</p>
        </z-tab>
        <z-tab label="Sixth">
          <p>Content of the sixth tab</p>
        </z-tab>
      </z-tab-group>
    </div>
  `,
})
export class ZardDemoTabsDefaultComponent {}
 

position

Is the default tab component

Tabs Position:
Active Tab Indicator Position:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
 
import { ZardDividerComponent } from '../../divider/divider.component';
import { ZardRadioComponent } from '../../radio/radio.component';
import { ZardTabComponent, ZardTabGroupComponent, type zPosition } from '../tabs.component';
 
@Component({
  selector: 'z-demo-tabs-position',
  standalone: true,
  imports: [ZardTabComponent, ZardTabGroupComponent, ZardRadioComponent, FormsModule, ZardDividerComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="flex h-[300px] w-full flex-col justify-between">
      <z-tab-group [zTabsPosition]="zTabsPosition" [zActivePosition]="zActivePosition" class="h-[180px]">
        <z-tab label="First">
          <p>Is the default tab component</p>
        </z-tab>
        <z-tab label="Second">
          <p>Content of the second tab</p>
        </z-tab>
        <z-tab label="Third">
          <p>Content of the third tab</p>
        </z-tab>
      </z-tab-group>
      <div>
        <z-divider class="my-0" />
        <div class="flex flex-col gap-3 px-4 py-2 text-sm">
          <div class="flex items-center justify-between">
            <span>Tabs Position:</span>
            <div class="flex gap-2">
              <span z-radio name="tab" [(ngModel)]="zTabsPosition" value="top" zSize="sm">Top</span>
              <span z-radio name="tab" [(ngModel)]="zTabsPosition" value="bottom" zSize="sm">Bottom</span>
              <span z-radio name="tab" [(ngModel)]="zTabsPosition" value="left" zSize="sm">Left</span>
              <span z-radio name="tab" [(ngModel)]="zTabsPosition" value="right" zSize="sm">Right</span>
            </div>
          </div>
          <div class="flex items-center justify-center gap-2">
            <span>Active Tab Indicator Position:</span>
            <span z-radio name="active" [(ngModel)]="zActivePosition" value="top" zSize="sm">Top</span>
            <span z-radio name="active" [(ngModel)]="zActivePosition" value="bottom" zSize="sm">Bottom</span>
            <span z-radio name="active" [(ngModel)]="zActivePosition" value="left" zSize="sm">Left</span>
            <span z-radio name="active" [(ngModel)]="zActivePosition" value="right" zSize="sm">Right</span>
          </div>
        </div>
      </div>
    </div>
  `,
})
export class ZardDemoTabsPositionComponent {
  protected zTabsPosition: zPosition = 'top';
  protected zActivePosition: zPosition = 'bottom';
}
 

align

zAlignTabs: start

import { Component } from '@angular/core';
 
import { ZardButtonComponent } from '../../button/button.component';
import { type zAlign, ZardTabComponent, ZardTabGroupComponent } from '../tabs.component';
 
@Component({
  selector: 'z-demo-tabs-align',
  standalone: true,
  imports: [ZardTabComponent, ZardTabGroupComponent, ZardButtonComponent],
  template: `
    <div class="h-[300px] w-full">
      <z-tab-group [zAlignTabs]="zAlignTabs">
        <z-tab label="First">
          <p class="w-full text-center">zAlignTabs: {{ zAlignTabs }}</p>
          <div class="mt-4 flex items-center justify-center gap-2">
            <button z-button zType="ghost" (click)="zAlignTabs = 'start'">Start</button>
            <button z-button zType="ghost" (click)="zAlignTabs = 'center'">Center</button>
            <button z-button zType="ghost" (click)="zAlignTabs = 'end'">End</button>
          </div>
        </z-tab>
        <z-tab label="Second">
          <p>Content of the second tab</p>
        </z-tab>
        <z-tab label="Third">
          <p>Content of the third tab</p>
        </z-tab>
      </z-tab-group>
    </div>
  `,
})
export class ZardDemoTabsAlignComponent {
  zAlignTabs: zAlign = 'start';
}
 

arrow

Is the default tab component

import { Component } from '@angular/core';
 
import { ZardButtonComponent } from '../../button/button.component';
import { ZardTabComponent, ZardTabGroupComponent } from '../tabs.component';
 
@Component({
  selector: 'z-demo-tabs-arrow',
  standalone: true,
  imports: [ZardTabComponent, ZardTabGroupComponent, ZardButtonComponent],
  template: `
    <div class="h-[300px] w-full">
      <div class="mb-4 text-sm">
        <div class="mt-4 flex items-center justify-center gap-2">
          <button z-button zType="ghost" type="button" [attr.aria-pressed]="showArrow" (click)="showArrow = !showArrow">{{ showArrow ? 'Hide' : 'Show' }} Arrows</button>
        </div>
      </div>
      <z-tab-group [zShowArrow]="showArrow">
        <z-tab label="First">
          <p>Is the default tab component</p>
        </z-tab>
        <z-tab label="Second">
          <p>Content of the second tab</p>
        </z-tab>
        <z-tab label="Third">
          <p>Content of the third tab</p>
        </z-tab>
        <z-tab label="Fourth">
          <p>Content of the fourth tab</p>
        </z-tab>
        <z-tab label="Fifth">
          <p>Content of the fifth tab</p>
        </z-tab>
        <z-tab label="Sixth">
          <p>Content of the sixth tab</p>
        </z-tab>
        <z-tab label="Seventh">
          <p>Content of the seventh tab</p>
        </z-tab>
        <z-tab label="Eighth">
          <p>Content of the eighth tab</p>
        </z-tab>
        <z-tab label="Ninth">
          <p>Content of the ninth tab</p>
        </z-tab>
        <z-tab label="Tenth">
          <p>Content of the tenth tab</p>
        </z-tab>
      </z-tab-group>
    </div>
  `,
})
export class ZardDemoTabsArrowComponent {
  showArrow = true;
}
 

API

[z-tab] Component

z-tab-group is a Component that allows you to create a tabbed interface with customizable navigation and active indicator positions.

To configure the tab group, pass the following props to the component.

Property Description Type Default
[zPosition] Position of the tab navigation top | bottom | left | right top
[zActivePosition] Position of the active indicator top | bottom | left | right bottom
[zShowArrow] Whether to show scroll arrows when content overflows true | false true
[zScrollAmount] Whether to show scroll arrows when content overflows number 100
[zAlignTabs] Alignment of tabs within the navigation start | center | end start
(zOnTabChange) Emits when a new tab is selected or index signal emit $event $event
(zDeselect) Emits when the current tab is deselected $event $event