ui-003

Animated Pill Navbar

£10 or the whole Vault, £99

An animated navbar with a sliding pill: it leans toward the cursor, stretches as it travels between links, and keeps the active link in view.

intermediate
7 more details
magneticquickToelasticmicro-interactionhover-effectkeyboard-navigationhorizontal-scroll

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

Updated

About this component

A tab bar that earns the word magnetic. Inside a hovered link the pill and its label lean toward the cursor with an eased falloff and a hard cap, so the bar feels alive under the pointer instead of merely snapping between links. The lean reuses one tween per property rather than starting a new one on every pointer move, so a per-frame handler stays cheap.

Read the full effect overview

The pill is also travel aware: it stretches along the axis in proportion to the distance it covers and settles with an elastic spring, so a hop to a neighbour and an end-to-end jump read as different gestures. Narrow bars scroll horizontally with fade edges and keep the current link in view, the keyboard gets exactly the same pill behaviour as the mouse, and a small public API with CustomEvents lets a router drive the bar and listen back.

It ships in neutral product tokens with a light and a dark theme on one data-variant attribute, so it drops into an application or a marketing site without arriving as a brand of its own; every colour is a custom property and the script never reads one.

Animated Pill Navbar - UI component preview

What's included

10 items
  • Real magnetism: the pill and its label are pulled toward the cursor inside a link, with an eased falloff and a capped offset
  • One reused tween per property, so a pointer move never spawns a new one
  • Travel-aware pill: it stretches along the axis in proportion to the distance covered and settles elastically, so a neighbour hop and an end-to-end jump read differently
  • Overflow handling: narrow bars scroll horizontally with fade edges and scroll the active link back into view
  • Full keyboard parity: roving tabindex, arrow keys, Home/End, Enter to select, and the pill returns to the active link on blur
  • Public API (setActive by index or href, active, links, refresh) plus pillnav:change, pillnav:preview and pillnav:release events for router wiring
  • Coarse-pointer answer: no hover-only state, taps move the pill, larger targets, and the bar swipes sideways
  • Measured indicator with left: 0 and x from getBoundingClientRect, re-measured on resize, after web fonts load and on a theme switch
  • The dark label travels with the pill rather than with the click, and lights up as the pill arrives
  • Light and dark themes on one data-variant attribute, every colour a custom property the script never reads

Perfect for

4 use cases
  • Secondary navigation on product and docs pages
  • Section switchers inside dashboards
  • Pricing and settings tab bars
  • Router-driven nav in a single-page app, kept in sync through setActive

How it works

4 sections

Measured indicator

Each link is measured with getBoundingClientRect relative to the track; the pill is positioned with x and width from those numbers and keeps left: 0 in CSS, so nothing is offset twice. It re-measures on resize, once document.fonts.ready resolves, and whenever the component is refreshed after a theme or content change.

Magnetism without a tween per frame

A pointermove handler normalises the cursor's offset from the hovered link's centre, eases it off with distance and caps it at a fifth of the link's width, then feeds it to the pill and to that link's label. One tween per property is reused rather than created per event, which is what keeps a per-frame handler cheap; leaving the bar sends everything back to zero.

Travel-aware stretch

The travel distance is divided by the bar's full span, and that ratio scales a scaleX stretch and a small scaleY squash on the pill's skin before an elastic.out settle returns it to 1. The pill's travel and its magnetism live on two nested elements, so the two never fight for the same transform.

Overflow and keyboard

The track sits in a horizontally scrolling viewport with gradient fade edges that appear only while it overflows; selecting or focusing a link tweens scrollLeft to bring it back into view. Focus moves the pill exactly as hover does, arrow keys and Home/End move a roving tabindex, and blurring the bar returns the pill to the active link.

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 Animated Pill Navbar 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

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Add the component markup:

Copy the [data-pillnav] block from index.html (it is marked with <!-- Component starts here -->). The structure it needs:

Code snippet omitted: it ships with the download.

Every link needs data-pillnav-link and an inner data-pillnav-label; the label is a separate element because it is pulled toward the cursor independently of the pill. Mark the current link with is-current (or aria-current) and the script starts there. Any number of links works, and labels can be any width.

Using It With Your Own Design

What the component needs from your markup: the seven data-pillnav-* hooks above, real <a href> links (never <div>s or href="#"), and the pill nested inside the same track as the links. Nothing about the surrounding page matters.

What is only the demo's styling: the toolbar strip at the top of index.html, the .stage* ground rules in assets/style.css, the Google Font link and the theme tokens themselves. Delete all of it and the bar keeps working; it takes its colours from whatever custom properties it inherits. The demo's click handler also calls preventDefault() so the links do not jump the page: remove that one line in assets/script.js to let them navigate for real.

CSS the component depends on:

  • No CSS transform on .pillnav__pill, .pillnav__skin or .pillnav__label. GSAP owns all three; a CSS transform underneath one is read as a pixel offset it never returns from.
  • .pillnav__pill keeps left: 0. Its position comes entirely from a measured x. A CSS left on top of that doubles the offset — the single most common way a sliding indicator ends up half a link out.
  • The pill is decoration, so it is opacity: 0 until the script adds .has-js to <html>. Without JavaScript the links are all still there and the current one underlines itself.
  • .pillnav__viewport is the horizontal scroller. It deliberately carries no data-lenis-prevent: on a horizontal-only scroller that attribute silently takes Lenis off the page's vertical wheel too.

What's Included

  • index.html: the bar alone on the demo ground, with a light/dark toggle
  • assets/style.css: component CSS, the two theme token blocks, plus the demo ground
  • assets/script.js: readable GSAP core source with the keyboard model, public API and cleanup
  • assets/script.min.js: minified production script
  • thumbnail.html: build-only capture state

Themes

Two themes ship in assets/style.css, light and dark: one design at two sets of custom properties. The script never reads a colour or a theme name, so re-valuing a block is the whole of a rebrand.

Pick one: set data-variant on <body> in the demo, or on the component's owning root in your page.

Code snippet omitted: it ships with the download.

Leave the attribute off for light. The demo's toolbar just sets this attribute, and index.html?variant=dark loads with one preselected. Both are demo-only.

The tokens. Each body[data-variant="..."] block at the top of assets/style.css sets the same short list, and the component's own tokens (below the blocks) are stated once in terms of it, so re-valuing this list is all a rebrand needs:

Token What it colours
--ground, --ground-2 the demo page and its toolbar strip (demo furniture)
--raised the bar itself
--hover control hover in the demo toolbar
--ink, --ink-2, --ink-3 the hovered label, the label at rest, meta text
--line, --line-strong the bar's hairline border
--accent the pill and the focus ring
--accent-ink the label riding on the pill
--shadow the bar's elevation
--font, --radius* Inter, and the radii

Re-value it for your brand: copy either block, change --accent and --accent-ink (check the label on the pill clears 4.5:1), and the bar follows. If you want a square bar instead of a pill, change --bar-radius and --indicator-radius in the component token block. A change that moves the font or the padding changes every measured width, so call window.magneticPillNav.refresh() afterwards; the demo's toggle shows the one line.

How It Works

Measured indicator. Each link is measured with getBoundingClientRect relative to the track, and the pill is positioned with x and width from those numbers while left stays 0. It re-measures on resize, once document.fonts.ready resolves, and on refresh().

Magnetism without a tween per frame. A pointermove handler normalises the cursor's offset from the hovered link's centre, eases it with a power falloff (calm in the middle, strong at the edges) and caps it at a fifth of the link's width, then hands it to gsap.quickTo setters on the pill's skin and on that link's label. quickTo reuses one tween per property, so a per-frame handler stays cheap. Leaving the bar sends every setter back to zero.

Travel-aware stretch. The distance covered is divided by the bar's full span; that ratio drives a scaleX stretch and a small scaleY squash before an elastic.out settle returns them to 1. A neighbour hop barely deforms; an end-to-end jump smears and springs back. Travel lives on .pillnav__pill and magnetism on the nested .pillnav__skin, so the two never fight over the same transform.

The dark label follows the pill, not the click — and on a long hop it lights up as the pill arrives, so no label is ever dark text on a dark bar while the pill is still in mid-air.

Overflow. The track sits in a horizontally scrolling viewport with gradient fade edges that fade in only while it actually overflows. Selecting or focusing a link tweens scrollLeft so that link comes back into view.

Public API. The first instance on the page is exposed for wiring (all instances are on window.magneticPillNavAll):

Code snippet omitted: it ships with the download.

setActive returns false for an index or href it cannot find, so a router can fall through safely.

Events. The root dispatches (all bubbling, all with { index, href, label, element }):

  • pillnav:change — the active link changed; detail.source is pointer, keyboard, api or init
  • pillnav:preview — the pill moved to a link on hover or focus, without selecting it
  • pillnav:release — the pointer left the bar and the pill returned to the active link

Code snippet omitted: it ships with the download.

Accessibility

  • The bar is a <nav> of real links. The current one carries aria-current="page", which the script keeps in sync.
  • Roving tabindex: the bar is one stop in the page tab order. Tab moves into the current link, and Tab again leaves the bar.
  • Left / Right (and Up / Down) move focus between links and move the pill with it, wrapping at both ends. Home and End jump to the first and last link. Enter or Space selects the focused link and fires pillnav:change.
  • Focus does exactly what hover does — it moves the pill — but it does not change the active link until you select. Blurring the bar returns the pill to the active link.
  • :focus-visible draws a ring in the accent, offset against the bar colour so it is visible in both themes.
  • The pill is aria-hidden decoration; nothing is announced twice.
  • Coarse pointers get no hover-only state, taller targets, taps that move the pill, and a bar that swipes sideways when it overflows. Magnetism is a fine-pointer enhancement and is simply absent on touch.
  • Under prefers-reduced-motion: reduce the pill moves to its target with no travel tween, no stretch and no magnetism; everything stays readable and in the right place.
  • Without JavaScript every link is present and the current one is underlined; only the pill is missing.

Customisation

  • Colours, fonts, radii and shadows are the custom properties in the two theme blocks at the top of assets/style.css, plus the component token block under them; the accent is #2563eb.
  • Magnet strength: the capX cap (a fifth of the link's width, max 16px), the 0.72 falloff exponent and the vertical 3.5 cap in pullTo in assets/script.js.
  • Stretch: the 0.42 and 0.12 factors in stretch, and the elastic.out(1, 0.55) settle.
  • Travel feel: the elastic.out(1, 0.78) and 0.55 duration in slide.

Requirements

  • GSAP 3.12+ (the demo uses 3.15.0; core only)
  • No build step, no framework

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