GrBottomNav
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 screen —
hideAboveremoves it, leaving the side navigation; - a section has a counter —
badgeshows the unread count right on the icon; - a
navigationlandmark is needed — the root renders as a<nav>tag.
When to take something else
| Need | Take |
|---|---|
| There are more than five sections | GrSidebar / GrDrawer |
| The top bar of the application | GrNavbar |
| Sections inside a single page | GrTabs |
| A bar of actions rather than navigation | GrButtonGroup |
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 field | What it does |
|---|---|
label, value | the label and the v-model value |
icon | a UnoCSS icon class (i-lucide-*) or a Vue component; decorative |
badge | a counter on top of the item |
badgeLabel | the label of the counter for a screen reader, stronger than the locale |
disabled | the section is visible but unreachable |
href / to | the item becomes a link |
ariaLabel | an 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.
Links
<GrBottomNav v-model="section" :as="RouterLink" :items="items" />
The root tag of an item is chosen like this: as (when the item has to) →
<a href> → <button>. A link gives the right click, “open in a new tab” and
prefetching — everything a button does not have. disabled makes the root a
<span>: an unreachable section must not stay in the Tab order.
GrLink is deliberately not imported inside: the dependency would drag its CSS to
the consumer for the sake of a single tag.
Visibility and layer
<GrBottomNav v-model="section" :items="items" hide-above="none" position="static" />
hideAbove(smby default, and alsomd,lg,none) — the breakpoint from which the bar is hidden.noneis for a kiosk and for a PWA, where the bottom navigation is permanent;position(fixedby default,static) —statictakes 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:
| Step | The bar | The glyph | The label |
|---|---|---|---|
xs | 48px | 16px | --gr-text-2xs |
sm | 56px | 20px | --gr-text-2xs |
md | 56px | 20px | --gr-text-xs |
lg | 64px | 24px | --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…
<GrBottomNav />Install
npm i @feugene/granularityImport
import { GrBottomNav } from '@feugene/granularity/components/GrBottomNav'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The 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. |
ariaLabel | string | undefined | undefined | The name of the landmark. If it is not set, it comes from the locale. |
as | string | Component | undefined | undefined | The 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. |
modelValuerequired | string | — | — |
itemsrequired | GrBottomNavItem[] | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
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
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: string] | — |
Examples 4
Custom item markup and the size scale
<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
<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
<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
<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
—