Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Tab Bar

A bottom tab bar for primary navigation.

A bottom tab bar for primary navigation, with a sliding thumb that follows the active tab.

Installation

npx touchcn add tabbar

Usage

<tcn-tabbar>
  <tcn-tab [active]="tab() === 0" (selected)="tab.set(0)">
    <svg tab-icon><!-- … --></svg>
    Home
  </tcn-tab>
  <tcn-tab [active]="tab() === 1" (selected)="tab.set(1)">
    <svg tab-icon><!-- … --></svg>
    Search
  </tcn-tab>
</tcn-tabbar>
import { TcnTabbar, TcnTab } from '@/components/ui/tabbar';

<TcnTabbar value={tab}>
  <TcnTab active={tab === 0} onSelect={() => setTab(0)} icon={<HomeIcon />}>
    Home
  </TcnTab>
  <TcnTab active={tab === 1} onSelect={() => setTab(1)} icon={<SearchIcon />}>
    Search
  </TcnTab>
</TcnTabbar>
<script setup lang="ts">
import { TcnTabbar, TcnTab } from '@/components/ui/tabbar';
</script>

<template>
  <TcnTabbar :value="tab">
    <TcnTab :active="tab === 0" @select="tab = 0">
      <template #icon><HomeIcon /></template>
      Home
    </TcnTab>
    <TcnTab :active="tab === 1" @select="tab = 1">
      <template #icon><SearchIcon /></template>
      Search
    </TcnTab>
  </TcnTabbar>
</template>

Props

TcnTabbar

Prop Type Description
value string | number Active tab value — repositions the sliding thumb (React).

TcnTab

Prop Type Default Description
active boolean false Whether this tab is selected.
icon ReactNode Tab icon (Angular: [tab-icon] slot).
onSelect / selected callback Fired when the tab is chosen.
Source · Angular
export * from './tcn-tabbar';
import { booleanAttribute, Component, computed, contentChildren, ElementRef, inject, input, output } from '@angular/core';
import { TcnRippleDirective } from '@touchcn/angular/ripple';
import { TcnTabThumbDirective } from '@touchcn/angular/tabs';

@Component({
  selector: 'tcn-tabbar',
  imports: [TcnTabThumbDirective],
  template: `
    <nav #pill="tcnTabThumb" tcnTabThumb [active]="activeTab()?.host?.nativeElement" class="tcn-material tcn-tabbar flex items-stretch">
      <span
        class="tcn-tab-thumb"
        aria-hidden="true"
        [class.tcn-tab-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>
      <ng-content />
    </nav>
  `,
  host: { class: 'tcn-safe-bottom fixed inset-x-0 bottom-0 z-40 flex justify-center' },
})
export class TcnTabbar {
  private readonly tabs = contentChildren(TcnTab);

  protected readonly activeTab = computed(() => this.tabs().find((tab) => tab.active()));
}

@Component({
  selector: 'tcn-tab',
  imports: [TcnRippleDirective],
  template: `
    <button
      type="button"
      tcnRipple
      class="tcn-tab relative flex flex-1 flex-col items-center justify-center gap-1 py-1.5"
      [attr.data-active]="active()"
      (click)="selected.emit()"
    >
      <span class="tcn-tab-indicator flex items-center justify-center rounded-full px-4 py-0.5">
        <ng-content select="[tab-icon]" />
      </span>
      <span class="text-[11px] font-medium leading-none">
        <ng-content />
      </span>
    </button>
  `,
  host: { class: 'flex flex-1' },
})
export class TcnTab {
  readonly host = inject(ElementRef<HTMLElement>);
  readonly active = input(false, { transform: booleanAttribute });
  readonly selected = output<void>();
}
Source · React
export * from './tcn-tabbar';
import { forwardRef, useCallback } from 'react';
import type {
  ComponentPropsWithoutRef,
  MouseEvent as ReactMouseEvent,
  PointerEvent as ReactPointerEvent,
  ReactNode,
} from 'react';
import { cn } from '@touchcn/core';
import { mergeRefs, useRipple, useTabThumb } from '@touchcn/react';

export interface TcnTabbarProps extends ComponentPropsWithoutRef<'div'> {
  /** Active tab value — changing it repositions the sliding thumb. */
  value?: string | number;
}

export const TcnTabbar = forwardRef<HTMLDivElement, TcnTabbarProps>(function TcnTabbar(
  { value, className, children, ...props },
  ref,
) {
  const { pillRef, thumbStyle, animate } = useTabThumb(value);

  return (
    <div ref={ref} className={cn('tcn-safe-bottom fixed inset-x-0 bottom-0 z-40 flex justify-center', className)} {...props}>
      <nav ref={pillRef} className="tcn-material tcn-tabbar flex items-stretch">
        <span className={cn('tcn-tab-thumb', !animate && 'tcn-tab-thumb--static')} aria-hidden="true" style={thumbStyle} />
        {children}
      </nav>
    </div>
  );
});

export interface TcnTabProps extends Omit<ComponentPropsWithoutRef<'button'>, 'onSelect'> {
  active?: boolean;
  icon?: ReactNode;
  onSelect?(): void;
}

export const TcnTab = forwardRef<HTMLButtonElement, TcnTabProps>(function TcnTab(
  { active = false, icon, onSelect, className, onClick, onPointerDown, onPointerCancel, children, ...props },
  forwardedRef,
) {
  const { hostRef, hostClassName, rippleHandlers } = useRipple<HTMLButtonElement>();

  const handleClick = useCallback(
    (event: ReactMouseEvent<HTMLButtonElement>) => {
      onClick?.(event);
      onSelect?.();
      if (!event.defaultPrevented) rippleHandlers.onClick(event);
    },
    [onClick, onSelect, rippleHandlers],
  );
  const handlePointerDown = useCallback(
    (event: ReactPointerEvent<HTMLButtonElement>) => {
      onPointerDown?.(event);
      if (!event.defaultPrevented) rippleHandlers.onPointerDown(event);
    },
    [onPointerDown, rippleHandlers],
  );
  const handlePointerCancel = useCallback(
    (event: ReactPointerEvent<HTMLButtonElement>) => {
      onPointerCancel?.(event);
      if (!event.defaultPrevented) rippleHandlers.onPointerCancel(event);
    },
    [onPointerCancel, rippleHandlers],
  );

  return (
    <button
      ref={mergeRefs(forwardedRef, hostRef)}
      type="button"
      data-active={active}
      className={cn(
        'tcn-tab relative flex flex-1 flex-col items-center justify-center gap-1 py-1.5',
        hostClassName,
        className,
      )}
      onClick={handleClick}
      onPointerDown={handlePointerDown}
      onPointerCancel={handlePointerCancel}
      {...props}
    >
      <span className="tcn-tab-indicator flex items-center justify-center rounded-full px-4 py-0.5">{icon}</span>
      <span className="text-[11px] font-medium leading-none">{children}</span>
    </button>
  );
});
Source · Vue
<script setup lang="ts">
import { cn } from '@touchcn/core';
import { useRipple } from '@touchcn/vue';

withDefaults(defineProps<{ active?: boolean }>(), { active: false });
const emit = defineEmits<{ select: [] }>();

const { setHost, hostClassName, onPointerdown, onPointercancel, onClick } = useRipple();

const handleClick = (event: MouseEvent): void => {
  emit('select');
  onClick(event);
};
</script>

<template>
  <button
    :ref="setHost"
    type="button"
    :data-active="active"
    :class="cn('tcn-tab relative flex flex-1 flex-col items-center justify-center gap-1 py-1.5', hostClassName)"
    @click="handleClick"
    @pointerdown="onPointerdown"
    @pointercancel="onPointercancel"
  >
    <span class="tcn-tab-indicator flex items-center justify-center rounded-full px-4 py-0.5">
      <slot name="icon" />
    </span>
    <span class="text-[11px] font-medium leading-none"><slot /></span>
  </button>
</template>
<script setup lang="ts">
import { cn } from '@touchcn/core';
import { useTabThumb } from '@touchcn/vue';

const props = defineProps<{
  /** Active tab value — changing it repositions the sliding thumb. */
  value?: string | number;
}>();

const { setPill, thumbStyle, animate } = useTabThumb(() => props.value);
</script>

<template>
  <div class="tcn-safe-bottom fixed inset-x-0 bottom-0 z-40 flex justify-center">
    <nav :ref="setPill" class="tcn-material tcn-tabbar flex items-stretch">
      <span
        :class="cn('tcn-tab-thumb', !animate && 'tcn-tab-thumb--static')"
        aria-hidden="true"
        :style="thumbStyle"
      />
      <slot />
    </nav>
  </div>
</template>
export { default as TcnTabbar } from './TcnTabbar.vue';
export { default as TcnTab } from './TcnTab.vue';

Last updated on July 24, 2026

Was this page helpful?