GrBottomNav
Bottom navigation for the key sections of a mobile interface.
Overview
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 |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrBottomNav
:items="items"
v-model="value"
hideAbove="none"
/>Examples 4 each with a live demo and its code
API generated from the package · cannot drift
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. |
modelValuerequiredv-model | string | — | The `value` of the active item (`v-model`): it gets `aria-current="page"` and the highlight. |
itemsrequired | GrBottomNavItem[] | — | The sections of the bar: a label, `value`, an icon, a counter, `href` or `to`. Usually three to five key sections within thumb reach. |
Slots
| Slot | Type | Description |
|---|---|---|
item |
| 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 | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: string): void | — |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrBottomNavItem, GrBottomNavProps, GrBottomNavEmits } from '@feugene/granularity/components/GrBottomNav'Filedist/types/components/GrBottomNav/GrBottomNav.vue.d.ts
type GrBottomNavItem = {
label: string;
value: string;
icon?: string | Component;
badge?: string | number;
badgeLabel?: string;
disabled?: boolean;
href?: string;
to?: unknown;
ariaLabel?: string;
};interface GrBottomNavProps {
modelValue: string;
items: GrBottomNavItem[];
as?: string | Component;
hideAbove?: GrBottomNavHideAbove;
position?: GrBottomNavPosition;
ariaLabel?: string;
size?: GrComponentSize;
}interface GrBottomNavEmits {
(e: 'update:modelValue', value: string): void;
}Filedist/types/components/GrBottomNav/defaults.d.ts
interface GrBottomNavConfigurableProps {
size: GrComponentSize;
}Filedist/types/components/GrBottomNav/config.d.ts
const grBottomNavConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrBottomNav">;Filedist/types/components/GrBottomNav/grBottomNavStyles.d.ts
type GrBottomNavHideAbove = typeof GR_BOTTOM_NAV_HIDE_ABOVE[number];type GrBottomNavPosition = typeof GR_BOTTOM_NAV_POSITIONS[number];Filedist/types/components/GrBottomNav/safelist.d.ts
const grBottomNavSafelist: string[];Accessibility
APG pattern
—
Keys
Enter / Space
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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 Vue component or an icon class (i-lucide-* — the application sets up the rule for it: in the engine factory or through a provider, otherwise a Vue component; see “Icons”); 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.
Props in this example