UIPackage
Menu

Framework

Change language

Boilerplate repo

Bottom Navigation

bottom-navigation ui

Mobile 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.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/bottom-navigation.json
Named registry: npx shadcn-vue@latest add @uipkge/bottom-navigation Installs to: app/components/ui/bottom-navigation/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
items

Tab items.

BottomNavItem[] required
modelValue

Active item value (v-model).

string '' optional
activeColor

Active item color — a Tailwind text color class. Default 'text-primary'.

string 'text-primary' optional
fixed

Fixed positioning at the viewport bottom. Default true.

boolean true optional
showIndicator

Show a sliding active indicator pill behind the icon. Default true.

boolean true optional
safeArea

Safe-area padding for notched devices (iOS). Default true.

boolean true optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

BottomNavItem
interface BottomNavItem {
  /** Unique value identifying this tab. Used with v-model. */
  value: string
  /** Label shown under the icon. */
  label: string
  /** Lucide icon component. */
  icon: Component
  /** Optional badge count or text shown on the icon. */
  badge?: string | number
  /** Router link destination (use with vue-router). */
  to?: string
}

npm dependencies

Files installed (2)

  • app/components/ui/bottom-navigation/BottomNavigation.vue 6.6 kB
    <script setup lang="ts">
    import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Component, type HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    export interface BottomNavItem {
      /** Unique value identifying this tab. Used with v-model. */
      value: string
      /** Label shown under the icon. */
      label: string
      /** Lucide icon component. */
      icon: Component
      /** Optional badge count or text shown on the icon. */
      badge?: string | number
      /** Router link destination (use with vue-router). */
      to?: string
    }
    
    interface Props {
      /** Tab items. */
      items: BottomNavItem[]
      /** Active item value (v-model). */
      modelValue?: string
      /** Active item color — a Tailwind text color class. Default 'text-primary'. */
      activeColor?: string
      /** Fixed positioning at the viewport bottom. Default true. */
      fixed?: boolean
      /** Show a sliding active indicator pill behind the icon. Default true. */
      showIndicator?: boolean
      /** Safe-area padding for notched devices (iOS). Default true. */
      safeArea?: boolean
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      modelValue: '',
      activeColor: 'text-primary',
      fixed: true,
      showIndicator: true,
      safeArea: true,
    })
    
    const emit = defineEmits<{
      'update:modelValue': [value: string]
      select: [item: BottomNavItem]
    }>()
    
    const active = computed(() => props.modelValue)
    
    const navEl = ref<HTMLElement | null>(null)
    const indicatorStyle = ref<Record<string, string>>({ opacity: '0' })
    let ro: ResizeObserver | null = null
    let firstPosition = true
    
    function motionSafeTransition() {
      if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        return 'none'
      }
      return 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)'
    }
    
    function updateIndicator() {
      if (!props.showIndicator) return
      const root = navEl.value
      if (!root) return
    
      const activeItem = root.querySelector<HTMLElement>('[data-slot="bottom-navigation-item"][data-active]')
      if (!activeItem) {
        indicatorStyle.value = { opacity: '0' }
        return
      }
    
      const iconWrap = activeItem.querySelector<HTMLElement>('[data-slot="bottom-navigation-icon"]') ?? activeItem
      const rootRect = root.getBoundingClientRect()
      const iconRect = iconWrap.getBoundingClientRect()
    
      // Compact pill centered on the icon — fixed height keeps motion light; width tracks icon + pad.
      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 = motionSafeTransition()
    
      indicatorStyle.value = {
        width: `${pillW}px`,
        height: `${pillH}px`,
        transform: `translate3d(${left}px, ${top}px, 0)`,
        opacity: '1',
        transition,
      }
      firstPosition = false
    }
    
    function unbindObservers() {
      ro?.disconnect()
      ro = null
    }
    
    function bindObservers() {
      const root = navEl.value
      if (!root || !props.showIndicator) return
    
      unbindObservers()
      ro = new ResizeObserver(() => updateIndicator())
      ro.observe(root)
      root.querySelectorAll('[data-slot="bottom-navigation-item"]').forEach((el) => ro!.observe(el))
      updateIndicator()
    }
    
    function onSelect(item: BottomNavItem) {
      if (item.to) {
        // Router integration — navigate if vue-router is available
        const router = (window as any).__vueRouter
        if (router) router.push(item.to)
      }
      emit('update:modelValue', item.value)
      emit('select', item)
    }
    
    onMounted(() => {
      nextTick(() => bindObservers())
    })
    
    onBeforeUnmount(() => {
      unbindObservers()
    })
    
    watch(
      () => props.modelValue,
      () => {
        nextTick(updateIndicator)
      },
    )
    
    watch(
      () => props.items,
      () => {
        // Remeasure without sliding when the item set changes (count/layout).
        firstPosition = true
        nextTick(() => bindObservers())
      },
      { deep: true },
    )
    
    watch(
      () => props.showIndicator,
      (on) => {
        firstPosition = true
        if (on) nextTick(() => bindObservers())
        else {
          unbindObservers()
          indicatorStyle.value = { opacity: '0' }
        }
      },
    )
    </script>
    
    <template>
      <nav
        ref="navEl"
        data-uipkge
        data-slot="bottom-navigation"
        aria-label="Bottom navigation"
        :data-fixed="fixed ? '' : undefined"
        :class="
          cn(
            'border-border bg-background/95 z-50 flex items-stretch justify-around border-t backdrop-blur-sm',
            // fixed establishes the containing block for the absolute indicator; relative when in-flow
            fixed ? 'fixed inset-x-0 bottom-0' : 'relative',
            safeArea && 'pb-[env(safe-area-inset-bottom)]',
            props.class,
          )
        "
      >
        <!-- Single sliding pill; transform-only for cheap mobile paint -->
        <span
          v-if="showIndicator"
          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="indicatorStyle"
        />
    
        <button
          v-for="item in items"
          :key="item.value"
          data-slot="bottom-navigation-item"
          :data-active="active === item.value ? '' : undefined"
          :aria-current="active === item.value ? 'page' : undefined"
          type="button"
          class="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"
          :class="active === item.value ? activeColor : 'text-muted-foreground hover:text-foreground'"
          @click="onSelect(item)"
        >
          <span data-slot="bottom-navigation-icon" class="relative flex items-center justify-center">
            <component
              :is="item.icon"
              class="size-5 transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none"
              :class="active === item.value ? 'scale-110' : 'scale-100'"
            />
            <span
              v-if="item.badge !== undefined && item.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
            class="max-w-full truncate px-1 transition-[opacity,font-weight] duration-200 motion-reduce:transition-none"
            :class="active === item.value ? 'font-medium' : 'font-normal'"
          >
            {{ item.label }}
          </span>
        </button>
      </nav>
    </template>
  • app/components/ui/bottom-navigation/index.ts 0.1 kB
    export { default as BottomNavigation } from './BottomNavigation.vue'
    export type { BottomNavItem } from './BottomNavigation.vue'

Raw manifest: https://uipkge.dev/r/vue/bottom-navigation.json