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