Bottom Navigation

A bottom bar whose highlight glides to the selected item, and that can fold to it or slide away while the page scrolls down.

<script setup lang="ts">
import {
  BottomNavigation,
  BottomNavigationAccessory,
  BottomNavigationHighlight,
  BottomNavigationHighlightItem,
  BottomNavigationItem,
  BottomNavigationLabel,
} from '@animate-ui/vue/primitives/bottom-navigation'
import { Button } from '@animate-ui/vue/primitives/buttons/button'
import { computed, ref } from 'vue'

import House from '@/components/animate-ui/icons/house/House.vue'
import Plus from '@/components/animate-ui/icons/plus/Plus.vue'
import Search from '@/components/animate-ui/icons/search/Search.vue'
import User from '@/components/animate-ui/icons/user/User.vue'
import Wallet from '@/components/animate-ui/icons/wallet/Wallet.vue'

const props = withDefaults(
  defineProps<{
    scrollBehavior?: 'none' | 'hide' | 'minimize'
    labels?: boolean | string
    accessory?: boolean | string
  }>(),
  { scrollBehavior: 'minimize', labels: true, accessory: true },
)

const showLabels = computed(() => String(props.labels) === 'true')
const showAccessory = computed(() => String(props.accessory) === 'true')

const ITEMS = [
  { value: 'home', label: 'Home', icon: House },
  { value: 'search', label: 'Search', icon: Search },
  { value: 'wallet', label: 'Wallet', icon: Wallet },
  { value: 'profile', label: 'Profile', icon: User },
]

const PAYMENTS = [
  { name: 'Spotify', time: '08:12', amount: '€10.99' },
  { name: 'Figma', time: '09:30', amount: '€15.00' },
  { name: 'Uber', time: '12:42', amount: '€6.05' },
  { name: 'Notion', time: '14:05', amount: '€8.00' },
  { name: 'Airbnb', time: '17:03', amount: '€256.73' },
  { name: 'Vercel', time: '18:21', amount: '€20.00' },
  { name: 'Apple', time: '19:18', amount: '€2.99' },
  { name: 'Dribbble', time: '20:47', amount: '€52.92' },
  { name: 'Netflix', time: '21:15', amount: '€13.49' },
  { name: 'Linear', time: '22:02', amount: '€8.00' },
]

const value = ref('home')
const presses = ref(0)
const scrollRef = ref<HTMLElement | null>(null)
</script>

<template>
  <div class="relative h-[440px] w-[340px] overflow-hidden rounded-[2rem] border bg-background shadow-sm">
    <div ref="scrollRef" class="h-full overflow-y-auto px-5 pt-6 pb-24">
      <p class="mb-3 text-xs font-medium tracking-wide text-muted-foreground uppercase">
        Today
      </p>
      <ul class="flex flex-col gap-4">
        <li v-for="payment in PAYMENTS" :key="payment.name" class="flex items-center gap-3">
          <span class="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
            {{ payment.name[0] }}
          </span>
          <span class="flex flex-1 flex-col">
            <span class="text-sm font-medium">{{ payment.name }}</span>
            <span class="text-xs text-muted-foreground">{{ payment.time }}</span>
          </span>
          <span class="text-sm font-medium tabular-nums">{{ payment.amount }}</span>
        </li>
      </ul>
    </div>
    <div class="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-background to-transparent" />
    <div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center">
      <BottomNavigation
        v-model="value"
        :scroll-behavior="scrollBehavior"
        :scroll-container="scrollRef"
        aria-label="Main"
        class="pointer-events-auto flex items-center gap-2"
      >
        <BottomNavigationHighlight class="absolute inset-0 z-0 rounded-full bg-muted">
          <ul class="flex items-center rounded-full border bg-background/80 p-1.5 shadow-lg backdrop-blur">
            <li v-for="item in ITEMS" :key="item.value">
              <BottomNavigationHighlightItem :value="item.value">
                <BottomNavigationItem :value="item.value" as-child>
                  <button
                    type="button"
                    :aria-label="item.label"
                    class="relative z-10 flex h-11 items-center justify-center rounded-full px-3.5 text-muted-foreground outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 hover:text-foreground data-[state=active]:text-foreground"
                  >
                    <component :is="item.icon" :animate="value === item.value" :initial="false" :size="20" />
                    <BottomNavigationLabel v-if="showLabels" class="pl-2 text-sm font-medium">
                      {{ item.label }}
                    </BottomNavigationLabel>
                  </button>
                </BottomNavigationItem>
              </BottomNavigationHighlightItem>
            </li>
          </ul>
        </BottomNavigationHighlight>
        <BottomNavigationAccessory
          v-if="showAccessory"
          class="rounded-full border bg-background/80 p-1.5 shadow-lg backdrop-blur"
        >
          <Button
            type="button"
            aria-label="New payment"
            class="flex size-11 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors duration-300 ease-out hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
            @click="presses++"
          >
            <Plus :key="presses" :animate="presses > 0" :size="20" />
          </Button>
        </BottomNavigationAccessory>
      </BottomNavigation>
    </div>
  </div>
</template>

Installation

npm install @animate-ui/vue motion-v
pnpm add @animate-ui/vue motion-v
yarn add @animate-ui/vue motion-v
bun add @animate-ui/vue motion-v

Usage

<script setup lang="ts">
import {
  BottomNavigation,
  BottomNavigationAccessory,
  BottomNavigationHighlight,
  BottomNavigationHighlightItem,
  BottomNavigationItem,
  BottomNavigationLabel,
} from '@animate-ui/vue/primitives/bottom-navigation';

const ITEMS = [
  { value: 'home', href: '/', label: 'Home', icon: HomeIcon },
  { value: 'search', href: '/search', label: 'Search', icon: SearchIcon },
];
</script>

<template>
  <BottomNavigation
    default-value="home"
    scroll-behavior="minimize"
    aria-label="Main"
    class="fixed inset-x-0 bottom-4 mx-auto flex w-fit items-center gap-2"
  >
    <BottomNavigationHighlight class="absolute inset-0 rounded-full bg-muted">
      <ul class="flex rounded-full border bg-background p-1.5">
        <li v-for="item in ITEMS" :key="item.value">
          <BottomNavigationHighlightItem :value="item.value">
            <BottomNavigationItem
              :value="item.value"
              :href="item.href"
              class="flex items-center px-4 py-2"
            >
              <component :is="item.icon" />
              <BottomNavigationLabel class="pl-2">{{
                item.label
              }}</BottomNavigationLabel>
            </BottomNavigationItem>
          </BottomNavigationHighlightItem>
        </li>
      </ul>
    </BottomNavigationHighlight>
    <BottomNavigationAccessory class="rounded-full border bg-background p-1.5">
      <button class="size-11 rounded-full hover:bg-muted">+</button>
    </BottomNavigationAccessory>
  </BottomNavigation>
</template>

Items are links: give them an href, or as-child with your router's link (RouterLink, NuxtLink) or a button. The selected item gets aria-current="page" and data-state="active". With a router, bind the current route with v-model so the highlight follows navigation. BottomNavigationHighlight and BottomNavigationHighlightItem are optional; without them, style the active item with data-state="active".

scroll-behavior sets what the bar does while the page scrolls down: "minimize" folds it to the selected item, like iOS' tab bar, "hide" slides it out, and "none" (the default) leaves it. It comes back as soon as the page scrolls up or reaches the top, an item takes focus, or the bar is tapped. It listens to the window, or to scroll-container (a template ref's element) when the page scrolls inside an element. To fold, items shrink to nothing, padding included: space them with padding rather than a gap, which would stay.

BottomNavigationLabel and BottomNavigationAccessory are optional:

  • A label shows only on the selected item, so the bar stays icons and one name. Its text stays in the accessibility tree, so it names the item even while closed.
  • The accessory (a button, a player, a search field) stays beside the bar, folded or not: it follows the bar as it folds. Pressing or focusing it leaves the bar folded.

A closed label takes no room: put its spacing inside it (pl-2), not on the item.

Animation

  • The highlight slides to the selected item on a spring (stiffness 300, damping 30): one surface is handed from the previous item to the new one, at full opacity (Highlight).
  • Items scale to 0.97 while pressed, on the buttons' spring.
  • Labels open on the highlight's spring, so the highlight widens with the selected item: the new label grows from nothing out of a 4px blur as the previous one narrows and blurs away.
  • Both scroll behaviors start once the page has scrolled down by 6px and run on a critically damped spring (stiffness 300, damping 35).
    • "minimize": the other items narrow to nothing as they fade out into a 4px blur, the bar closing around the selected one with its highlight; they come back the same way.
    • "hide": the bar slides out past the bottom edge, and back on the way up.
  • <AnimateUIProvider :tempo> paces every spring.
  • Under reduced motion, the highlight moves at once, items don't scale, labels change width at once (fading), the bar folds at once, and the hidden bar fades out instead of sliding (and can’t be clicked while hidden).

API Reference

BottomNavigation

PropTypeDefault
default-value?
string-
v-model?
string-
@update:model-value?
(value: string) => void-
scroll-behavior?
'none' | 'hide' | 'minimize''none'
scroll-container?
HTMLElement | null-
transition?
Transition{ type: 'spring', stiffness: 300, damping: 35, restDelta: 0.01 }
...props?
HTMLAttributes-

BottomNavigationHighlight

Takes the Highlight props, except controlled-items and model-value.

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 300, damping: 30 }

BottomNavigationHighlightItem

Takes the HighlightItem props.

PropTypeDefault
value
string-

BottomNavigationItem

PropTypeDefault
as-child?
booleanfalse
value
string-
tap-scale?
number0.97
...props?
HTMLAttributes-

BottomNavigationLabel

Goes inside a BottomNavigationItem.

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 300, damping: 30 }
...props?
HTMLAttributes-

BottomNavigationAccessory

PropTypeDefault
...props?
HTMLAttributes-

useBottomNavigation

Returns activeValue(), minimized(), scrollBehavior() and scrollTransition() getters, and handleValueChange.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/25/2026