Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Tabs

Content tabs with a sliding indicator and panel switching.

Content tabs switch between related views within the same screen (distinct from the bottom Tabbar). Material renders primary tabs with a sliding underline indicator; iOS renders a segmented control with a sliding thumb.

Installation

npx touchcn add tabs

Usage

<tcn-tabs [(value)]="tab">
  <tcn-tab-panel value="overview" label="Overview">…</tcn-tab-panel>
  <tcn-tab-panel value="details" label="Details">…</tcn-tab-panel>
</tcn-tabs>
import { TcnTabs, TcnTabsList, TcnTab, TcnTabPanel } from '@/components/ui/tabs';

<TcnTabs value={tab} onValueChange={setTab}>
  <TcnTabsList>
    <TcnTab value="overview">Overview</TcnTab>
    <TcnTab value="details">Details</TcnTab>
  </TcnTabsList>
  <TcnTabPanel value="overview">…</TcnTabPanel>
  <TcnTabPanel value="details">…</TcnTabPanel>
</TcnTabs>
<script setup lang="ts">
import { TcnTabs, TcnTabsList, TcnTab, TcnTabPanel } from '@/components/ui/tabs';
</script>

<template>
  <TcnTabs v-model="tab">
    <TcnTabsList>
      <TcnTab value="overview">Overview</TcnTab>
      <TcnTab value="details">Details</TcnTab>
    </TcnTabsList>
    <TcnTabPanel value="overview">…</TcnTabPanel>
    <TcnTabPanel value="details">…</TcnTabPanel>
  </TcnTabs>
</template>

Props

Prop Type Default Description
value string Active tab value (two-way on Angular).
onValueChange (value: string) => void Active-tab callback (React).
label string '' Tab label (Angular tcn-tab-panel).
Source · Angular
export * from './tcn-tabs';
import {
  Component,
  computed,
  contentChildren,
  ElementRef,
  inject,
  input,
  model,
  viewChildren,
} from '@angular/core';
import { TcnTabsKeyNavDirective, TcnTabThumbDirective } from '@touchcn/angular/tabs';

/**
 * Content tabs (distinct from the bottom Tabbar). A tablist selects among
 * projected `tcn-tab-panel`s; the active panel shows and the rest are hidden.
 * MD3 renders primary tabs with a sliding underline indicator; iOS renders a
 * segmented control with a sliding thumb — both reuse the engine tab-thumb
 * measurement (`tcnTabThumb`) and only differ in skin. Arrow-key roving is
 * delegated to the engine `tcnTabsKeyNav` directive.
 */
@Component({
  selector: 'tcn-tabs',
  imports: [TcnTabThumbDirective, TcnTabsKeyNavDirective],
  template: `
    <div
      #pill="tcnTabThumb"
      tcnTabThumb
      [active]="activeTabEl()"
      tcnTabsKeyNav
      [items]="tabEls()"
      (activate)="onActivate($event)"
      role="tablist"
      class="tcn-tabs-list"
    >
      <span
        class="tcn-tabs-thumb"
        aria-hidden="true"
        [class.tcn-tabs-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 (panel of panels(); track panel.value()) {
        <button
          #tabBtn
          type="button"
          role="tab"
          class="tcn-tabs-tab"
          [id]="tabId(panel.value())"
          [attr.data-active]="panel.value() === activeValue()"
          [attr.aria-selected]="panel.value() === activeValue()"
          [attr.aria-controls]="panelId(panel.value())"
          [attr.tabindex]="panel.value() === activeValue() ? 0 : -1"
          (click)="select(panel.value())"
        >
          {{ panel.label() }}
        </button>
      }
    </div>
    <div class="tcn-tabs-panels">
      <ng-content />
    </div>
  `,
  host: { class: 'block' },
})
export class TcnTabs {
  readonly value = model('');

  protected readonly panels = contentChildren(TcnTabPanel);
  private readonly tabButtons = viewChildren<ElementRef<HTMLButtonElement>>('tabBtn');

  protected readonly tabEls = computed(() => this.tabButtons().map((ref) => ref.nativeElement));

  /** The effective active value — the model, or the first panel until one is set. */
  readonly activeValue = computed(() => this.value() || this.panels()[0]?.value() || '');

  protected readonly activeTabEl = computed(() => {
    const index = this.panels().findIndex((panel) => panel.value() === this.activeValue());
    return this.tabButtons()[index]?.nativeElement ?? null;
  });

  select(value: string): void {
    this.value.set(value);
  }

  protected onActivate(element: HTMLElement): void {
    const index = this.tabEls().indexOf(element as HTMLButtonElement);
    const panel = this.panels()[index];
    if (panel) {
      this.select(panel.value());
    }
  }

  protected tabId(value: string): string {
    return `tcn-tab-${value}`;
  }

  protected panelId(value: string): string {
    return `tcn-tabpanel-${value}`;
  }
}

/** A single tab panel projected into a `tcn-tabs`; `label` names its tab. */
@Component({
  selector: 'tcn-tab-panel',
  template: `
    <div role="tabpanel" class="tcn-tab-panel" [id]="panelId()" [attr.aria-labelledby]="tabId()" [hidden]="!active()">
      <ng-content />
    </div>
  `,
  host: { class: 'block' },
})
export class TcnTabPanel {
  private readonly tabs = inject(TcnTabs);

  readonly value = input.required<string>();
  readonly label = input('');

  protected readonly active = computed(() => this.tabs.activeValue() === this.value());
  protected readonly panelId = computed(() => `tcn-tabpanel-${this.value()}`);
  protected readonly tabId = computed(() => `tcn-tab-${this.value()}`);
}
Source · React
export * from './tcn-tabs';
import { createContext, useContext } from 'react';
import type { Ref, ReactNode } from 'react';
import { useTabThumb, useTabsKeyNav } from '@touchcn/react';

interface TabsContextValue {
  value: string;
  select(value: string): void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabsContext(): TabsContextValue {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('TcnTabsList / TcnTab / TcnTabPanel must be used within a <TcnTabs>.');
  }
  return context;
}

export interface TcnTabsProps {
  value: string;
  onValueChange(value: string): void;
  children?: ReactNode;
}

/**
 * Content tabs (distinct from the bottom Tabbar). MD3 renders primary tabs with
 * a sliding underline indicator; iOS renders a segmented control with a sliding
 * thumb — both reuse the engine `useTabThumb` measurement and only differ in
 * skin. Roving arrow-key navigation is delegated to the engine `useTabsKeyNav`
 * hook (mirroring the Angular `tcnTabsKeyNav` directive), keeping the emitted
 * `tcn-*` classes and `data-active` attributes identical across frameworks.
 */
export function TcnTabs({ value, onValueChange, children }: TcnTabsProps) {
  return (
    <div className="block">
      <TabsContext.Provider value={{ value, select: onValueChange }}>{children}</TabsContext.Provider>
    </div>
  );
}

export interface TcnTabsListProps {
  children?: ReactNode;
}

export function TcnTabsList({ children }: TcnTabsListProps) {
  const { value, select } = useTabsContext();
  const { pillRef, thumbStyle, animate } = useTabThumb(value, '.tcn-tabs-tab[data-active="true"]');
  const { onKeyDown } = useTabsKeyNav(pillRef, (tab) => {
    const next = tab.getAttribute('data-value');
    if (next) {
      select(next);
    }
  });

  return (
    <div ref={pillRef as Ref<HTMLDivElement>} role="tablist" className="tcn-tabs-list" onKeyDown={onKeyDown}>
      <span className={`tcn-tabs-thumb${animate ? '' : ' tcn-tabs-thumb--static'}`} aria-hidden="true" style={thumbStyle} />
      {children}
    </div>
  );
}

export interface TcnTabProps {
  value: string;
  disabled?: boolean;
  children?: ReactNode;
}

export function TcnTab({ value, disabled, children }: TcnTabProps) {
  const { value: active, select } = useTabsContext();
  const selected = active === value;
  return (
    <button
      type="button"
      role="tab"
      id={`tcn-tab-${value}`}
      data-value={value}
      data-active={selected}
      aria-selected={selected}
      aria-controls={`tcn-tabpanel-${value}`}
      tabIndex={selected ? 0 : -1}
      disabled={disabled}
      className="tcn-tabs-tab"
      onClick={() => select(value)}
    >
      {children}
    </button>
  );
}

export interface TcnTabPanelProps {
  value: string;
  children?: ReactNode;
}

export function TcnTabPanel({ value, children }: TcnTabPanelProps) {
  const { value: active } = useTabsContext();
  return (
    <div
      role="tabpanel"
      id={`tcn-tabpanel-${value}`}
      aria-labelledby={`tcn-tab-${value}`}
      className="tcn-tab-panel"
      hidden={active !== value}
    >
      {children}
    </div>
  );
}
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import { useTcnTabsContext } from './context';

const props = defineProps<{ value: string; disabled?: boolean }>();

const context = useTcnTabsContext();
const selected = computed(() => context.value.value === props.value);
</script>

<template>
  <button
    type="button"
    role="tab"
    :id="`tcn-tab-${value}`"
    :data-value="value"
    :data-active="selected"
    :aria-selected="selected"
    :aria-controls="`tcn-tabpanel-${value}`"
    :tabindex="selected ? 0 : -1"
    :disabled="disabled"
    class="tcn-tabs-tab"
    @click="context.select(value)"
  >
    <slot />
  </button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useTcnTabsContext } from './context';

const props = defineProps<{ value: string }>();

const context = useTcnTabsContext();
const hidden = computed(() => context.value.value !== props.value);
</script>

<template>
  <div
    role="tabpanel"
    :id="`tcn-tabpanel-${value}`"
    :aria-labelledby="`tcn-tab-${value}`"
    class="tcn-tab-panel"
    :hidden="hidden"
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue';
import { TcnTabsKey } from './context';

/** Two-way bound active tab value (`v-model`). */
const model = defineModel<string>({ required: true });

/**
 * Content tabs (distinct from the bottom Tabbar). MD3 renders primary tabs with
 * a sliding underline indicator; iOS renders a segmented control with a sliding
 * thumb — both reuse the engine `useTabThumb` measurement and only differ in
 * skin. Roving arrow-key navigation is delegated to the engine `useTabsKeyNav`
 * hook, keeping the emitted `tcn-*` classes and `data-active` attributes
 * identical across frameworks.
 */
provide(TcnTabsKey, {
  value: computed(() => model.value),
  select: (next: string) => {
    model.value = next;
  },
});
</script>

<template>
  <div class="block"><slot /></div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useTabsKeyNav, useTabThumb } from '@touchcn/vue';
import { useTcnTabsContext } from './context';

const context = useTcnTabsContext();

const { setPill, thumbStyle, animate } = useTabThumb(
  () => context.value.value,
  '.tcn-tabs-tab[data-active="true"]',
);

const listRef = ref<HTMLElement | null>(null);
const setListBoth = (el: unknown): void => {
  setPill(el);
  listRef.value = el instanceof HTMLElement ? el : null;
};

const { onKeyDown } = useTabsKeyNav(listRef, (tab) => {
  const next = tab.getAttribute('data-value');
  if (next) {
    context.select(next);
  }
});
</script>

<template>
  <div :ref="setListBoth" role="tablist" class="tcn-tabs-list" @keydown="onKeyDown">
    <span
      :class="['tcn-tabs-thumb', !animate && 'tcn-tabs-thumb--static']"
      aria-hidden="true"
      :style="thumbStyle"
    />
    <slot />
  </div>
</template>
import { inject } from 'vue';
import type { ComputedRef, InjectionKey } from 'vue';

export interface TcnTabsContext {
  /** The active tab value. */
  value: ComputedRef<string>;
  /** Select a tab by value. */
  select(value: string): void;
}

/** Injection key shared between `TcnTabs` and its `TcnTabsList` / `TcnTab` / `TcnTabPanel`. */
export const TcnTabsKey: InjectionKey<TcnTabsContext> = Symbol('tcn-tabs');

export function useTcnTabsContext(): TcnTabsContext {
  const context = inject(TcnTabsKey, null);
  if (!context) {
    throw new Error('TcnTabsList / TcnTab / TcnTabPanel must be used within a <TcnTabs>.');
  }
  return context;
}
export { default as TcnTabs } from './TcnTabs.vue';
export { default as TcnTabsList } from './TcnTabsList.vue';
export { default as TcnTab } from './TcnTab.vue';
export { default as TcnTabPanel } from './TcnTabPanel.vue';

Last updated on July 24, 2026

Was this page helpful?