GrBottomNav

  • core
  • navigation
  • API · 7 props
  • ⌨ keys

Bottom navigation for the key sections of a mobile interface.

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

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 — hideAbove removes it, leaving the side navigation;
  • a section has a counter — badge 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

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • Sections of a banking app

    v-model switches the screen, and the active section differs by background, weight and colour at once. badge counts what waits in a section and badgeLabel names it — approve both payments and the counter leaves the bar. A disabled section stays visible but out of the Tab order: investing opens after the ID check. In an app the bar is fixed and hides from sm up; here position="static" and hide-above="none" put it into the frame.

    Accounts
    Main account · DE89 •••• 4410€12,480.50
    Contoso LtdToday+€2,400.00
    Office rentYesterday−€1,850.00
    Tailspin Toys3 Oct+€310.00
  • An avatar instead of an icon

    The item slot replaces the content of every item, and «Me» gets the avatar a messenger puts there. The tag, aria-current and the click stay with the component, and the slot hands over badgeLabel — the ready text for the counter. Past 99 the badge is the string «99+», and badgeLabel keeps the real 128; «Me» says little on its own, so the item has its own ariaLabel.

    Chats
    GWGrace WuThe VAT return is ready for review14:02
    FTFinance teamOmar: invoices for October are out13:47
    LRLinus RayCan we move the call to Friday?11:30
    MOMina OrtizThanks, received!Mon
  • Sections that are routes

    When every section has an address, the router owns the state: items carry to, as renders them with the app’s link component — RouterLink, NuxtLink, Inertia’s Link — and model-value follows the current route instead of v-model. A link keeps the right click and «open in a new tab» that a button does not have.

    Northwind Office#/orders
    Order #4815 · out for deliveryArrives today, 14:00–16:00
    Order #4790 · delivered28 September
  • A kiosk keeps the bar at any width

    On a self-service screen the bottom bar is the main navigation, not a mobile fallback: hide-above="none" keeps it on a wide display. size="lg" grows the bar, the glyph and the label together for a reader at arm’s length, while the touch target stays 44×44 at every step.

    Order here · Harbour Café1 in the cart · €3.20
    Flat white€3.20
    Cinnamon bun€2.90
    Tomato soup€5.50
    Club sandwich€7.40

API generated from the package · cannot drift

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.
modelValuerequiredv-modelstring—The `value` of the active item (`v-model`): it gets `aria-current="page"` and the highlight.
itemsrequiredGrBottomNavItem[]—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

SlotTypeDescription
item
{ item: GrBottomNavItem; active: boolean; disabled: boolean;{ 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

EventSignatureDescription
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
import type { GrBottomNavItem, GrBottomNavProps, GrBottomNavEmits } from '@feugene/granularity/components/GrBottomNav'

Filedist/types/components/GrBottomNav/GrBottomNav.vue.d.ts

GrBottomNavItemtype
type GrBottomNavItem = {
    label: string;
    value: string;
    icon?: string | Component;
    badge?: string | number;
    badgeLabel?: string;
    disabled?: boolean;
    href?: string;
    to?: unknown;
    ariaLabel?: string;
};
GrBottomNavPropsinterface
interface GrBottomNavProps {
    modelValue: string;
    items: GrBottomNavItem[];
    as?: string | Component;
    hideAbove?: GrBottomNavHideAbove;
    position?: GrBottomNavPosition;
    ariaLabel?: string;
    size?: GrComponentSize;
}
GrBottomNavEmitsinterface
interface GrBottomNavEmits {
    (e: 'update:modelValue', value: string): void;
}

Filedist/types/components/GrBottomNav/defaults.d.ts

GrBottomNavConfigurablePropsinterface
interface GrBottomNavConfigurableProps {
    size: GrComponentSize;
}

Filedist/types/components/GrBottomNav/config.d.ts

grBottomNavConfigconst
const grBottomNavConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrBottomNav">;

Filedist/types/components/GrBottomNav/grBottomNavStyles.d.ts

GrBottomNavHideAbovetype
type GrBottomNavHideAbove = typeof GR_BOTTOM_NAV_HIDE_ABOVE[number];
GrBottomNavPositiontype
type GrBottomNavPosition = typeof GR_BOTTOM_NAV_POSITIONS[number];

Filedist/types/components/GrBottomNav/safelist.d.ts

grBottomNavSafelistconst
const grBottomNavSafelist: string[];

Accessibility

APG pattern

—

Keys

Enter / Space

Usage from the library prose

The sections
vue
<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 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
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
vue
<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
vue
<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:

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