Bottom Navigation
bottom-navigationuiMobile bottom tab bar with icon + label items. Supports v-model for the active item, an active color, fixed positioning at the viewport bottom, badges on items, and a `to` prop for vue-router integration.
Installation
$pnpm dlx uipkge-ng@latest add bottom-navigation$npx uipkge-ng@latest add bottom-navigation$yarn dlx uipkge-ng@latest add bottom-navigation$bunx uipkge-ng@latest add bottom-navigationManifest:
https://uipkge.dev/r/angular/bottom-navigation.jsonInstalls to:src/app/components/ui/bottom-navigation/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | BottomNavItem[] | [] | optional |
value | string | — | optional |
defaultValue | string | — | optional |
modelValue | string | — | optional |
activeColor | string | text-primary | optional |
fixed | boolean | true | optional |
showIndicator | boolean | true | optional |
safeArea | boolean | true | optional |
class | string | — | optional |
valueChange | EventEmitter<string> | — | optional |
onValueChange | EventEmitter<string> | — | optional |
modelValueChange | EventEmitter<string> | — | optional |
select | EventEmitter<BottomNavItem> | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
BottomNavIteminterface BottomNavItem {
/** Unique value identifying this tab. Used with value / modelValue. */
value: string
/** Label shown under the icon. */
label: string
/** Icon: SVG string, template, or text. */
icon?: string | TemplateRef<unknown>
/** Optional badge count or text shown on the icon. */
badge?: string | number
/** Link destination (renders an anchor instead of a button). */
to?: string
}Files installed (2)
src/app/components/ui/bottom-navigation/bottom-navigation.component.ts10.9 kB
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, TemplateRef, booleanAttribute, inject, ChangeDetectionStrategy, } from '@angular/core' import { DomSanitizer, SafeHtml } from '@angular/platform-browser' import { NgTemplateOutlet } from '@angular/common' import { cn } from '@/lib/utils' export interface BottomNavItem { /** Unique value identifying this tab. Used with value / modelValue. */ value: string /** Label shown under the icon. */ label: string /** Icon: SVG string, template, or text. */ icon?: string | TemplateRef<unknown> /** Optional badge count or text shown on the icon. */ badge?: string | number /** Link destination (renders an anchor instead of a button). */ to?: string } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-bottom-navigation, [ui-bottom-navigation]', standalone: true, imports: [NgTemplateOutlet], host: { '[attr.data-slot]': '"bottom-navigation"', '[attr.data-uipkge]': '""', '[attr.data-fixed]': "fixed ? '' : null", role: 'navigation', '[attr.aria-label]': '"Bottom navigation"', '[class]': 'hostClass', }, template: ` @if (showIndicator) { <span data-slot="bottom-navigation-indicator" aria-hidden="true" class="bg-primary/10 pointer-events-none absolute top-0 left-0 z-0 rounded-full will-change-transform" [style.width]="indicatorStyle['width']" [style.height]="indicatorStyle['height']" [style.transform]="indicatorStyle['transform']" [style.opacity]="indicatorStyle['opacity']" [style.transition]="indicatorStyle['transition']" ></span> } @for (item of items; track item.value) { @if (item.to) { <a [href]="item.to" data-slot="bottom-navigation-item" [attr.data-active]="isActive(item) ? '' : null" [attr.aria-current]="isActive(item) ? 'page' : null" [class]="itemClass(item)" (click)="onSelect(item)" > <span data-slot="bottom-navigation-icon" class="relative flex items-center justify-center"> @if (isSvg(item.icon)) { <span class="flex size-5 items-center justify-center transition-transform duration-200 motion-reduce:transition-none [&>svg,&>lucide-icon>svg]:size-5" [class.scale-110]="isActive(item)" [innerHTML]="sanitize(item.icon)" aria-hidden="true" ></span> } @else if (isTemplate(item.icon)) { <ng-container [ngTemplateOutlet]="item.icon" /> } @else { <span class="flex size-5 items-center justify-center transition-transform duration-200 motion-reduce:transition-none" [class.scale-110]="isActive(item)" aria-hidden="true" >{{ item.icon ?? '●' }}</span > } @if (item.badge !== undefined && item.badge !== '') { <span data-slot="bottom-navigation-badge" class="bg-destructive text-destructive-foreground absolute -top-1.5 -right-2 flex min-w-4 items-center justify-center rounded-full px-1 text-xs leading-4 font-medium" > {{ item.badge }} </span> } </span> <span data-slot="bottom-navigation-label" [class]="labelClass(item)" > {{ item.label }} </span> </a> } @else { <button type="button" data-slot="bottom-navigation-item" [attr.data-active]="isActive(item) ? '' : null" [attr.aria-current]="isActive(item) ? 'page' : null" [class]="itemClass(item)" (click)="onSelect(item)" > <span data-slot="bottom-navigation-icon" class="relative flex items-center justify-center"> @if (isSvg(item.icon)) { <span class="flex size-5 items-center justify-center transition-transform duration-200 motion-reduce:transition-none [&>svg,&>lucide-icon>svg]:size-5" [class.scale-110]="isActive(item)" [innerHTML]="sanitize(item.icon)" aria-hidden="true" ></span> } @else if (isTemplate(item.icon)) { <ng-container [ngTemplateOutlet]="item.icon" /> } @else { <span class="flex size-5 items-center justify-center transition-transform duration-200 motion-reduce:transition-none" [class.scale-110]="isActive(item)" aria-hidden="true" >{{ item.icon ?? '●' }}</span > } @if (item.badge !== undefined && item.badge !== '') { <span data-slot="bottom-navigation-badge" class="bg-destructive text-destructive-foreground absolute -top-1.5 -right-2 flex min-w-4 items-center justify-center rounded-full px-1 text-xs leading-4 font-medium" > {{ item.badge }} </span> } </span> <span data-slot="bottom-navigation-label" [class]="labelClass(item)" > {{ item.label }} </span> </button> } } `, }) export class UiBottomNavigationComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() items: BottomNavItem[] = [] @Input() value?: string @Input() defaultValue = '' @Input() modelValue = '' @Input() activeColor = 'text-primary' @Input({ transform: booleanAttribute }) fixed = true @Input({ transform: booleanAttribute }) showIndicator = true @Input({ transform: booleanAttribute }) safeArea = true @Input('class') className?: string @Output() valueChange = new EventEmitter<string>() @Output() onValueChange = new EventEmitter<string>() @Output() modelValueChange = new EventEmitter<string>() @Output() select = new EventEmitter<BottomNavItem>() private ro: ResizeObserver | null = null private firstPosition = true private prevItemsKey = '' private readonly sanitizer: DomSanitizer | null = null private readonly el: ElementRef<HTMLElement> | null = null constructor() { try { this.sanitizer = inject(DomSanitizer, { optional: true }) } catch {} try { this.el = inject<ElementRef<HTMLElement>>(ElementRef, { optional: true }) } catch {} } private sanitizedCache = new Map<string, SafeHtml>() indicatorStyle: Record<string, string> = { opacity: '0' } get resolvedValue(): string { if (this.value !== undefined) return this.value if (this.modelValue !== '') return this.modelValue return this.defaultValue } get hostClass(): string { return cn( 'border-border bg-background/95 z-50 flex items-stretch justify-around border-t backdrop-blur-sm', this.fixed ? 'fixed inset-x-0 bottom-0' : 'relative', this.safeArea && 'pb-[env(safe-area-inset-bottom)]', this.className, ) } isActive(item: BottomNavItem): boolean { return this.resolvedValue === item.value } itemClass(item: BottomNavItem): string { return cn( 'focus-visible:ring-ring/50 relative z-10 flex min-h-12 flex-1 flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 text-xs transition-colors duration-200 outline-none focus-visible:ring-[3px] motion-reduce:transition-none', this.isActive(item) ? this.activeColor : 'text-muted-foreground hover:text-foreground', ) } labelClass(item: BottomNavItem): string { return cn( 'max-w-full truncate px-1 transition-[opacity,font-weight] duration-200 motion-reduce:transition-none', this.isActive(item) ? 'font-medium' : 'font-normal', ) } onSelect(item: BottomNavItem): void { this.modelValue = item.value this.value = item.value this.valueChange.emit(item.value) this.onValueChange.emit(item.value) this.modelValueChange.emit(item.value) this.select.emit(item) queueMicrotask(() => this.updateIndicator()) } isSvg(val: unknown): boolean { return typeof val === 'string' && val.includes('<svg') } isTemplate(val: unknown): boolean { return val instanceof TemplateRef } sanitize(svg: string): SafeHtml | string { if (!this.sanitizer) return svg let safe = this.sanitizedCache.get(svg) if (!safe) { safe = this.sanitizer.bypassSecurityTrustHtml(svg) this.sanitizedCache.set(svg, safe) } return safe } ngAfterViewInit(): void { this.updateIndicator() if (this.el && typeof ResizeObserver !== 'undefined') { this.ro = new ResizeObserver(() => this.updateIndicator()) const root = this.el.nativeElement this.ro.observe(root) root.querySelectorAll('[data-slot="bottom-navigation-item"]').forEach((el) => this.ro?.observe(el)) } } ngOnChanges(changes: SimpleChanges): void { // Track item identity so layout swaps remount without a false slide (React parity). if (changes['items']) { const key = this.items.map((i) => i.value).join('\0') if (key !== this.prevItemsKey) { this.prevItemsKey = key this.firstPosition = true } } if (changes['showIndicator']) this.firstPosition = true if (changes['value'] || changes['modelValue'] || changes['items'] || changes['showIndicator']) { queueMicrotask(() => this.updateIndicator()) } } ngOnDestroy(): void { this.ro?.disconnect() this.ro = null } private motionSafeTransition(): string { if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return 'none' } return this.firstPosition ? 'none' : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)' } private updateIndicator(): void { if (!this.showIndicator || !this.el) { this.indicatorStyle = { opacity: '0' } return } const root = this.el.nativeElement const activeItem = root.querySelector<HTMLElement>('[data-slot="bottom-navigation-item"][data-active]') if (!activeItem) { this.indicatorStyle = { opacity: '0' } return } const iconWrap = activeItem.querySelector<HTMLElement>('[data-slot="bottom-navigation-icon"]') ?? activeItem const rootRect = root.getBoundingClientRect() const iconRect = iconWrap.getBoundingClientRect() const padX = 14 const pillH = 32 const pillW = Math.max(iconRect.width + padX * 2, 56) const left = iconRect.left - rootRect.left + root.scrollLeft + (iconRect.width - pillW) / 2 const top = iconRect.top - rootRect.top + root.scrollTop + (iconRect.height - pillH) / 2 const transition = this.motionSafeTransition() this.indicatorStyle = { width: `${pillW}px`, height: `${pillH}px`, transform: `translate3d(${left}px, ${top}px, 0)`, opacity: '1', transition, } this.firstPosition = false } }src/app/components/ui/bottom-navigation/index.ts0.1 kB
export { UiBottomNavigationComponent, type BottomNavItem } from './bottom-navigation.component'
Raw manifest:https://uipkge.dev/r/angular/bottom-navigation.json