GrBottomNav

Package: @feugene/granularitycoreGroup: navigation

Bottom navigation for the key sections of a mobile interface.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a mobile interface — three to five key sections in the thumb zone;
  • the bar is not needed on a wide screenhideAbove removes it, leaving the side navigation;
  • a section has a counterbadge shows the unread count right on the icon;
  • a navigation landmark is needed — the root renders as a <nav> tag.

When to take something else

NeedTake
There are more than five sectionsGrSidebar / GrDrawer
The top bar of the applicationGrNavbar
Sections inside a single pageGrTabs
A bar of actions rather than navigationGrButtonGroup

The sections

<GrBottomNav
  v-model="section"
  :items="[
    { label: 'Feed', value: 'feed', icon: 'i-lucide-newspaper' },
    { label: 'Tasks', value: 'tasks', icon: 'i-lucide-check-square', badge: 12 },
    { label: 'Profile', value: 'profile', icon: 'i-lucide-user' },
  ]"
/>
Item fieldWhat it does
label, valuethe label and the v-model value
icona UnoCSS icon class (i-lucide-*) or a Vue component; decorative
badgea counter on top of the item
badgeLabelthe label of the counter for a screen reader, stronger than the locale
disabledthe section is visible but unreachable
href / tothe item becomes a link
ariaLabelan accessible name for when the label is not enough

Where the user is

The active item is declared aria-current="page" and differs by three signs at once: the background, the weight of the label and the colour. Colour alone is not enough — it is invisible to monochrome vision and does not exist for a screen reader (WCAG 1.4.1).

The landmark is always named: without a name the <nav> of the bar is indistinguishable from the main navigation of the page. The default comes from the locale (gr.bottomNav.label), and ariaLabel overrides it.

The component does not use role="tab": bottom navigation switches pages rather than panels inside one screen, and a tablist without a tabpanel is a broken pattern.

The counter

The number is drawn decoratively, with a visually hidden label beside it: a bare “12” tells a screen reader nothing. A numeric value is announced through the locale (gr.bottomNav.badge), a string one is read as it is, and badgeLabel overrides both.

Visibility and layer

<GrBottomNav v-model="section" :items="items" hide-above="none" position="static" />
  • hideAbove (sm by default, and also md, lg, none) — the breakpoint from which the bar is hidden. none is for a kiosk and for a PWA, where the bottom navigation is permanent;
  • position (fixed by default, static) — static takes the bar out of the fixed layer and puts it into the flow as an ordinary block: that way it can be built into the layout.

In fixed mode the layer is --gr-z-bottom-nav (850): the lowest in the scale, so an open dropdown, a tooltip or a modal covers the bar. The details — ../z-index.md.

The bottom padding accounts for env(safe-area-inset-bottom) — on a phone with a gesture bar the labels do not slide under it.

States

disabled dims the item with the --gr-disabled-fg token rather than with transparency: opacity dilutes text colours that were tuned to AA.

Size

<GrBottomNav v-model="section" :items="items" size="lg" />

<GrConfigProvider size="sm">

</GrConfigProvider>

The step pulls three things at once:

StepThe barThe glyphThe label
xs48px16px--gr-text-2xs
sm56px20px--gr-text-2xs
md56px20px--gr-text-xs
lg64px24px--gr-text-sm

What the step does not touch is the size of the item itself: min-width and min-height stay at 44px on every step. A touch target smaller than 44×44 is a failure of WCAG 2.5.5, and “make the bar more compact” is no reason to fall into it. The practical consequence: xs squeezes the bar out of the air around the content, not out of the area the finger aims at.

The value arrives by the shared rule: the local prop → componentDefaults → the global size of the provider → md (see ../sizes.md).

An item of your own

An icon, a label and a counter do not cover everything: in a messenger an avatar stands where the profile icon would be. The item slot hands over the content of the item as a whole:

<GrBottomNav v-model="section" :items="items">
  <template #item="{ item, active, disabled, badgeLabel }">
    <GrAvatar v-if="item.value === 'me'" :size="24" alt="Ann Lee" />
    <span v-else :class="item.icon" class="block h-5 w-5" aria-hidden="true" />
    <span class="truncate leading-none">{{ item.label }}</span>
  </template>
</GrBottomNav>

The slot changes the content, not the behaviour: the choice of tag (button / a / the component from as), aria-current, aria-disabled and the click handling stay with the component. That is why a custom item cannot accidentally stop being navigation.

Taking over the markup, you take over the announcing of the counter too: the visually hidden label is drawn together with the badge. The ready string arrives through the slot (badgeLabel) — it is enough to put it into sr-only, the locale and the item’s badgeLabel are already taken into account.

Playground 5

Loading…

Code
<GrBottomNav />

Install

npm i @feugene/granularity

Import

import { GrBottomNav } from '@feugene/granularity/components/GrBottomNav'

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size step: the height of the bar, the glyph and the type size of the label. The touch target of an item stays 44×44 on every step.
ariaLabelstring | undefinedundefinedThe name of the landmark. If it is not set, it comes from the locale.
asstring | Component | undefinedundefinedThe link component for items with `to`: `RouterLink`, `NuxtLink`, Inertia’s `Link`.
hideAbove"sm" | "md" | "lg" | "none" | undefined"sm"The breakpoint from which the bar is hidden. `none` — always visible.
position"fixed" | "static" | undefined"fixed"`static` takes the bar out of the fixed layer — for building it into the layout.
modelValuerequiredstring
itemsrequiredGrBottomNavItem[]

Slots

SlotTypeDescription
item{ item: GrBottomNavItem; active: boolean; disabled: boolean; badgeLabel: string | undefined; }The content of an item instead of the icon, the label and the counter. The root of the item — the tag, `aria-current`, `aria-disabled` and the click — stays with the component.

Events

EventTypeDescription
update:modelValue[value: string]

Examples 4

Custom item markup and the size scale

Custom Item
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrAvatar, GrBottomNav, GrSegmented } from '@feugene/granularity'
import type { GrComponentSize } from '@feugene/granularity'

const section = ref('chats')
const size = ref<GrComponentSize>('md')

const sizes = [
  { value: 'xs', label: 'XS' },
  { value: 'sm', label: 'SM' },
  { value: 'md', label: 'MD' },
  { value: 'lg', label: 'LG' },
]

const items = [
  { label: 'Chats', value: 'chats', icon: 'i-lucide-message-circle', badge: 5 },
  { label: 'Calls', value: 'calls', icon: 'i-lucide-phone' },
  { label: 'Ann Lee', value: 'me' },
]

// Забрав разметку пункта, размер глифа компонент за вас уже не считает.
const glyphClass = computed(() => ({ xs: 'h-4 w-4', sm: 'h-5 w-5', md: 'h-5 w-5', lg: 'h-6 w-6' })[size.value])
const avatarSize = computed(() => ({ xs: 20, sm: 24, md: 24, lg: 28 })[size.value])
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented
      :model-value="size"
      :options="sizes"
      size="sm"
      class="justify-self-start"
      @update:model-value="size = $event as GrComponentSize"
    />

    <GrBottomNav
      v-model="section"
      :items="items"
      :size="size"
      position="static"
      hide-above="none"
    >
      <template #item="{ item, badgeLabel }">
        <GrAvatar
          v-if="item.value === 'me'"
          :size="avatarSize"
          alt="Ann Lee"
        >
          AL
        </GrAvatar>
        <span
          v-else-if="item.icon"
          :class="[item.icon, glyphClass]"
          class="block shrink-0"
          aria-hidden="true"
        />

        <span class="max-w-full truncate leading-none">{{ item.label }}</span>

        <template v-if="item.badge">
          <span
            class="absolute right-1 top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-[var(--gr-radius-full)] bg-[var(--gr-danger)] px-1 text-[length:var(--gr-text-2xs)] font-700 text-[var(--gr-danger-fg)]"
            aria-hidden="true"
          >{{ item.badge }}</span>
          <span class="sr-only">{{ badgeLabel }}</span>
        </template>
      </template>
    </GrBottomNav>
  </div>
</template>

Basic section switcher

Active section
Overview

Basic Flow
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBottomNav, GrCard } from '@feugene/granularity'

const currentSection = ref('overview')
const items = [
  { label: 'Overview', value: 'overview', icon: 'i-lucide-layout-dashboard' },
  { label: 'Invoices', value: 'invoices', icon: 'i-lucide-receipt', badge: 3 },
  { label: 'Team', value: 'team', icon: 'i-lucide-users' },
]

const activeLabel = computed(() => {
  return items.find(item => item.value === currentSection.value)?.label ?? 'Overview'
})
</script>

<template>
  <div class="grid gap-4">
    <GrCard class="p-4">
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Active section
      </div>
      <div class="text-base font-semibold">
        {{ activeLabel }}
      </div>
    </GrCard>

    <GrBottomNav
      v-model="currentSection"
      :items="items"
      position="static"
      hide-above="none"
    />
  </div>
</template>

External state sync

`v-model` keeps the bottom navigation in sync with external actions, and a disabled destination stays visible without being reachable.
Current section: feed

External State
<script setup lang="ts">
import { ref } from 'vue'

import { GrBottomNav, GrButton, GrCard } from '@feugene/granularity'

const currentSection = ref('feed')
const items = [
  { label: 'Feed', value: 'feed', icon: 'i-lucide-newspaper' },
  { label: 'Tasks', value: 'tasks', icon: 'i-lucide-check-square', badge: 12 },
  { label: 'Billing', value: 'billing', icon: 'i-lucide-credit-card', disabled: true },
  { label: 'Profile', value: 'profile', icon: 'i-lucide-user' },
]
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" variant="outline" @click="currentSection = 'feed'">
        Open feed
      </GrButton>
      <GrButton size="sm" variant="outline" @click="currentSection = 'tasks'">
        Jump to tasks
      </GrButton>
      <GrButton size="sm" variant="outline" @click="currentSection = 'profile'">
        Focus profile
      </GrButton>
    </div>

    <GrCard class="p-4">
      <div class="text-sm text-[var(--gr-muted-fg)]">
        `v-model` keeps the bottom navigation in sync with external actions, and a disabled
        destination stays visible without being reachable.
      </div>
      <div class="mt-2 text-base font-semibold capitalize">
        Current section: {{ currentSection }}
      </div>
    </GrCard>

    <GrBottomNav
      v-model="currentSection"
      :items="items"
      position="static"
      hide-above="none"
    />
  </div>
</template>

Mobile shell composition

approvals
Items become real links, so a right click or “open in new tab” works as anywhere else.

Mobile Shell
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBottomNav, GrCard } from '@feugene/granularity'

const currentSection = ref('approvals')
const items = [
  { label: 'Approvals', value: 'approvals', icon: 'i-lucide-check-check', href: '#approvals' },
  { label: 'Calendar', value: 'calendar', icon: 'i-lucide-calendar', href: '#calendar' },
  { label: 'Settings', value: 'settings', icon: 'i-lucide-settings', href: '#settings' },
]

const sectionDescriptions: Record<string, string> = {
  approvals: 'Items become real links, so a right click or “open in new tab” works as anywhere else.',
  calendar: 'The bar keeps the current destination announced as the current page, not just coloured.',
  settings: 'In a real app the bar is fixed to the bottom edge and hidden on wide screens by default.',
}

const sectionDescription = computed(() => {
  return sectionDescriptions[currentSection.value] ?? sectionDescriptions.approvals
})
</script>

<template>
  <div class="mx-auto grid max-w-80 gap-0 overflow-hidden rounded-3xl border border-[var(--gr-brd)]">
    <GrCard class="rounded-none border-0 p-4">
      <div class="text-base font-semibold capitalize">
        {{ currentSection }}
      </div>
      <div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
        {{ sectionDescription }}
      </div>
    </GrCard>

    <GrBottomNav
      v-model="currentSection"
      :items="items"
      position="static"
      hide-above="none"
      aria-label="Mobile shell sections"
    />
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components