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