ui-028

Bottom Navigation Bar

£10 or the whole Vault, £99

A mobile bottom navigation bar: the indicator stretches between tabs, panels swipe with the finger and the bar hides as you scroll down.

Draggable intermediate
10 more details
micro-interactionmorphingspring-physicselasticclick-statekeyboard-navigationdraggabletouch-dragsnapvelocity

This element reads better at your own screen size than in the frame below:

Updated

About this component

The bottom navigation of a mobile app or an installable web app: tabs of icon and label, with an indicator that stretches toward the tab it is travelling to and springs back to that tab's width when it lands. The selected icon comes up on an elastic ease, a notification badge pops on the tab with unread items, and the bar tucks away while you scroll down and returns the moment you flick back up.

Read the full effect overview

The tab panels sit in a swipeable pager: drag sideways and the panels follow the finger while the indicator tracks the drag between tabs, then snap by velocity or at halfway; vertical scrolling inside a panel still works. It mounts with TabBar.mount(el, options), runs any number of instances, fires a tabbar:change event and destroys cleanly. Built on a nav of links, tablist ARIA with arrow keys, safe-area padding, light and dark themes. Core GSAP plus Draggable.

Bottom Navigation Bar - UI component preview

What's included

10 items
  • Indicator slides and stretches between tabs, then springs back to width
  • Selected icon rises on an elastic ease and its label fades in under it
  • Notification badge pops in on a back ease and clears when its tab is opened
  • Bar hides on scroll down and returns on scroll up, velocity-aware
  • Swipeable panels: the indicator tracks the drag, release snaps by velocity or halfway
  • Direction locks on the first move, so vertical scrolling in a panel still works
  • TabBar.mount API with select, badge and destroy, a change event and multiple instances
  • Built from a nav of links with tablist ARIA, arrow keys, Home and End
  • Safe-area padding keeps the bar clear of the home indicator
  • Light and dark themes on one set of tokens

Perfect for

4 use cases
  • Installable web apps and mobile-first products
  • Bottom navigation with swipeable sections in a phone-width app shell
  • Content feeds that need the full screen while reading
  • Prototypes and design reviews that need a real tab bar rather than a picture of one

How it works

4 sections

The stretch is the morph

Selecting a tab starts a short timeline. The indicator's x tweens to the measured offset on power3, its width overshoots by a fraction of the distance travelled, and then settles back to the target tab's own width on elastic.out. A long jump therefore stretches further than a jump to the next tab along, which is what makes the movement read as one shape moving rather than a box being repositioned.

A pager that follows the finger

Draggable runs on an invisible proxy with the pager as its trigger. The first movement decides the axis: a vertical gesture is left to the panel's own scroll, a horizontal one moves the track with the pointer and places the indicator between the two neighbouring tabs in proportion, interpolating x and width. Release goes one panel in the flick's direction when it was fast, and to whichever panel is more than half on screen when it was not.

Hide on scroll, velocity-aware

Each scroll event yields a distance and a time, so the bar keeps a smoothed velocity and a running distance in the current direction. Sixteen pixels down tucks it by its own height plus the gap it floats above the edge; a slow scroll up brings it back after 64px and a flick brings it back at once. It is always shown at the top and the end of the content, and when focus moves into it.

Drop-in and clean to remove

TabBar.mount(el, options) returns select, badge, reveal, hide, refresh and destroy, and fires tabbar:change with the index and the source of the change. Every tween lives in one gsap.context and every attribute the script touches is recorded first, so destroy reverts the context, kills the Draggable, removes listeners and restores the markup exactly.

Plugins Draggable
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Bottom Navigation Bar opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source exposing one global, TabBar

  • Indicator slides and stretches between tabs, then springs back to width

  • Selected icon rises on an elastic ease and its label fades in under it

  • Notification badge pops in on a back ease and clears when its tab is opened

  • Hide on scroll: the bar tucks away scrolling down and comes back scrolling up, velocity-aware, always shown at the top and the end of the content

  • Swipeable pager: panels drag with the finger, the indicator tracks the drag between tabs, and release snaps by velocity or at halfway

  • Vertical scrolling inside a panel still works: the direction locks on the first move

  • Drop-in API: TabBar.mount(el, options), any number of bars per page, destroy() that restores the markup

  • Measured offsets, so four tabs or six need no code change

  • Safe-area padding keeps the bar clear of the home indicator

  • Built from a nav of links: no JavaScript still navigates

  • Arrow keys, Home and End walk the bar, with a visible focus ring

Quick Start

Code snippet omitted: it ships with the download.

The markup is a nav of .tab links, an optional pager of panels, and one call:

Code snippet omitted: it ships with the download.

Each tab is an <a> with a .tab-icon (any inline SVG) and a .tab-label, plus an optional data-panel naming its panel and an optional data-label (the text written into a title element, if you pass one). Add or remove tabs freely: every offset is measured.

Without script, the same markup mounts itself when the nav carries data-tab-bar: data-pager="#pager", data-scroller="#main", data-title="#screenTitle" and data-hide-on-scroll map to the options below. Markup from the earlier version (#tabBar, #tabIndicator, #screenTitle) still mounts on its own, without a pager.

The screen around the bar is demo furniture. The 390px column, its header, the week summary and the run rows are .stage-* classes in assets/style.css, there only to give the bar a screen to sit at the foot of; delete them and the component is unaffected. The toolbar across the top of the demo is demo-only too, and lives in index.html alone, as does the badge that comes back after you leave Activity (wired through the tabbar:change event).

API

Code snippet omitted: it ships with the download.

Method What it does
bar.select(index, { instant }) Selects a tab: indicator, icon, panel and pager all move. instant: true skips the motion
bar.badge(index, count) Sets a tab's badge count; above zero pops it, zero clears it
bar.reveal() / bar.hide() Shows or tucks the bar from script, a modal opening for example
bar.refresh() Re-measures after a layout change the resize event does not see
bar.destroy() Removes every listener, tween, inline style and attribute it added, and the Draggable
bar.index The selected index
TabBar.get(el) The instance mounted on an element, or null. Mounting twice returns the same instance

Event. tabbar:change fires on the nav (and bubbles) whenever the selection changes after mount, with detail: { index, previousIndex, tab, panel, source }, where source is 'click', 'keyboard', 'swipe' or 'api'.

Code snippet omitted: it ships with the download.

What the bar watches for scroll. With scroller given, that element (or window). Without it, if the pager's panels scroll themselves (overflow-y: auto, the default in style.css), each panel, reading only the one on screen. Otherwise window, which is what a page-scrolled app wants.

Keyboard and ARIA

  • With panels, the nav becomes a role="tablist" of role="tab" links, each with aria-selected and aria-controls, and each panel a role="tabpanel". Tabs use a roving tabindex, so Tab lands on the selected tab and then moves into its panel.
  • Without panels, it stays a plain <nav aria-label="Primary"> of links, and the selected one carries aria-current="page".
  • Left and Right arrows walk the bar and select as they go, wrapping at both ends; Home and End jump to the first and last tab. Enter follows the link, which fires the same handler a click does.
  • Panels the pager has moved off screen are inert and aria-hidden, so neither Tab nor a screen reader wanders into them. The panel on screen is focusable, so a keyboard user can scroll it.
  • Focus moving into a tucked-away bar brings it back, so a keyboard user never tabs into an invisible bar.
  • :focus-visible draws a 2px --focus-ring outline offset from the pill, visible in both themes.
  • The badge count is announced: the visible digit is followed by an .sr-only word ("3 new").
  • The indicator is aria-hidden, being decoration; the state it shows is carried by aria-selected or aria-current.
  • Without JavaScript the tabs are links to the panels' ids and the first panel shows.

How It Works

The stretch is the morph. Selecting a tab starts a short timeline. The indicator's x tweens to the measured offset on power3, its width overshoots by a fraction of the distance travelled, and then settles back to the target tab's own width on elastic.out. A long jump stretches further than a jump to the next tab along, which is what makes the movement read as one shape moving rather than a box being repositioned.

Measured, never offset twice. The indicator keeps left: 0 in CSS and takes its whole horizontal position from each tab's offsetLeft and offsetWidth, which are relative to the bar's padding box (where the indicator sits) and are not changed by a page zoom or by the bar's own hide transform. It re-measures on resize and after web fonts land.

Swiping the pager. Draggable runs on an invisible proxy with the pager as its trigger, so the script, not Draggable, decides what moves. On the first movement it compares horizontal and vertical travel: a mostly vertical gesture is left alone for the panel's own scroll (on touch, Draggable hands it to the browser outright). A horizontal one moves the track with the pointer, rubber-banding past the first and last panel, and places the indicator between the two tabs either side in proportion, interpolating both x and width. On release, a flick faster than 0.35px per millisecond goes one panel in its direction; anything slower goes to whichever panel is more than half on screen. The settle then runs the same stretch as a tap, from wherever the finger left the indicator.

Hide on scroll. Each scroll event gives a distance and a time since the last one, so the bar keeps a lightly smoothed velocity and a running distance in the current direction. Scrolling down more than 16px, past the bar's own height, tucks it: it slides down by its height plus whatever gap it floats above the edge. Scrolling up brings it back after 64px, or at once when the velocity says it was a flick. A few pixels of jitter in either direction does neither. At the top and at the end of the content it is always shown, and a tab change shows it and restarts the tracking for the new panel.

Spring and badge. The selected icon is tweened with fromTo, from slightly low and small to slightly high and large on elastic.out, so the bounce happens every time. The previous tab's icon returns on a plain power2 ease. Start states are set by GSAP rather than CSS, so nothing carries a transform the tweens would add to.

Clean teardown. Every tween runs inside one gsap.context, and every attribute the script changes is recorded before it changes, so destroy() reverts the context, kills the Draggable, removes the listeners and puts the attributes back. Mounting again afterwards starts from the original markup.

Themes

The component ships in two themes, light and dark, which are the same design at two sets of token values. Every colour, radius and shadow it uses is a CSS custom property, so the script is identical in both and reads no theme name.

Pick one: set the attribute on <body> (or on the component's own wrapper in your page).

Code snippet omitted: it ships with the download.

Leaving the attribute off gives you dark, the demo's default. The demo's toggle and the ?variant=light URL parameter just set this attribute; neither is part of the component.

Re-value it for your brand: edit the two body[data-variant="..."] blocks at the top of assets/style.css. The component's tokens are --accent and --accent-fill (the accent as a ground under white text, one step deeper so 11px labels clear 4.5:1), --accent-ink, the bar surface (--bar-bg, --bar-border, --bar-radius, --bar-shadow, --bar-pad), the indicator (--indicator-bg, --indicator-radius, --indicator-shadow), the two tab colours (--tab-fg, --tab-fg-active), the badge (--badge-bg, --badge-fg), the label type (--label-size, --label-weight, --label-tracking, --label-transform), --font-body and --focus-ring. The ground tokens in the same blocks (--ground, --raised, --hover, --ink, --ink-2, --ink-3, --line) belong to the demo screen and can be deleted with it.

One token, --indicator-stretch, is read by the script at the moment a tween runs, so a theme, a media query or a line of JavaScript can dial the morph from restrained to rubbery without touching the animation code.

Customisation

  • Colours, radii and fonts live in assets/style.css as custom properties on each body[data-variant] block.
  • Stretch amount: --indicator-stretch, and the Math.min(travel * 0.4, 48) cap in moveIndicator.
  • Settle: the elastic.out(1, 0.62) on the second width tween. back.out(2) gives a shorter, firmer landing.
  • Icon spring: the fromTo in activate, from { y: 6, scale: 0.84 } to { y: -2, scale: 1.06 }.
  • Swipe feel: the flick threshold (0.35, px per ms) and the rubber band (0.3) in the pager section of assets/script.js.
  • Hide on scroll: the 16px, 64px and -0.5 velocity thresholds in onScroll.
  • Page-scrolled panels: give .tab-panel overflow: visible and the pager follows the page scroll, its height following the active panel.
  • Badge: drive it from real data with bar.badge(index, count); remove the .tab-badge span and nothing badge-related runs.
  • Labels: gsap.set(label(tab), { opacity: 0 }) in the start-state block hides inactive labels. Delete that line and all of them stay visible.
  • Safe area: padding-bottom: calc(var(--bar-pad) + env(safe-area-inset-bottom, 0px)) on .tab-bar, with the same inset taken off the indicator's height.

Reduced motion. Every state change lands instantly, the bar never hides on scroll, and a swipe still follows the finger (that is direct manipulation, not animation) but settles with a brief, flat snap rather than a spring.

Requirements

  • GSAP 3.12+ (demo uses 3.15.0)
  • Draggable, only for the swipeable pager; without it tabs still slide the pager
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.