Pill nav
A navigation bar whose highlight is alive.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe pill is laid over the links and the text it covers is a copy drawn over the pill, so the box that holds the links must not clip (overflow: hidden) when the pill reaches past its edge, and its last link is no longer its:last-child. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Use it on your own elements
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributesSelect the element on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-pill-nav Marks the box that holds the links. It takes no value: leave the value field empty. That box is what the pill travels in, what the effect is mounted on and what carries the settings. Without it on any box of the page, the embed marks the parent of the first link it finds next to itself, and says so in the console when there is none. data-pill-nav-link Marks one link the pill can sit on, inside that box. It takes no value. Without it on any link, every link, button and tab of the box is taken. [ Optional ] aria-current="page" Marks the current link, the one the pill rests on and comes back to. Webflow writes it by itself on a link to the page being shown. Without it on any link the pill is hidden at rest and shows under the pointer; a click then moves it to the clicked link. [ Optional ] data-pill-nav-state Written by the engine on the box: live while the pill travels, idle at rest, empty when no link is current and none is under the pointer. Never written by hand. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same element. An attribute always wins over the defaults written in the code.Attribute Default What it is data-pill-nav-color #ffab1a Colour of the pill, the amber #ffab1a by default. Options: any CSS colour; taken out of this block, --pill-nav-color on the bar takes over. data-pill-nav-ink #15100c Colour of the text where the pill covers it, the ink #15100c by default. Options: any CSS colour that reads on color; taken out of this block, --pill-nav-ink on the bar takes over. data-pill-nav-radius 100vmax Corner radius of the pill, any CSS unit; a radius larger than half the pill is brought back to a full capsule. data-pill-nav-pad 0em How far the pill reaches past the box of a link, any CSS unit, one value for the four sides or two, vertical then horizontal. data-pill-nav-stiffness 260 Pull of the spring that carries the leading edge of the pill. data-pill-nav-damping 22 Friction of the spring. data-pill-nav-stretch 0.5 How much softer the trailing edge is than the leading one, 0 to 1, and how much the pill thins while it is long. data-pill-nav-orientation auto Axis the pill stretches along. Options: auto reads it from each move, the longer way between the two links; horizontal and vertical hold it to one axis whatever the move. data-pill-nav-spy 0 Scroll spy: the link whose section is on screen becomes the current one while the page scrolls. Options: 0 off, the current link is the one marked in the markup or clicked; 1 on, for links to anchors of the same page.
Two files, no build step.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
PillNav.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-pill-nav-color #ffab1a Colour of the pill, the amber #ffab1a by default. Options: any CSS colour; taken out of this block, --pill-nav-color on the bar takes over. data-pill-nav-ink #15100c Colour of the text where the pill covers it, the ink #15100c by default. Options: any CSS colour that reads on color; taken out of this block, --pill-nav-ink on the bar takes over. data-pill-nav-radius 100vmax Corner radius of the pill, any CSS unit; a radius larger than half the pill is brought back to a full capsule. Towards 0 a plain rectangle, a tab marker A full capsule from half the height of a link on data-pill-nav-pad 0em How far the pill reaches past the box of a link, any CSS unit, one value for the four sides or two, vertical then horizontal. At 0 the pill is exactly the box of the link, right for links that carry their own padding A pill wider than its link, for bare text links; 0.3em 0.8em suits most data-pill-nav-stiffness 260 Pull of the spring that carries the leading edge of the pill. A slow, heavy pill that drags behind the pointer A pill that snaps to the link, past 600 almost at once data-pill-nav-damping 22 Friction of the spring. Under 14 the pill bounces several times before it rests Past 32 no bounce at all, the pill glides in and the landing loses its squash data-pill-nav-stretch 0.5 How much softer the trailing edge is than the leading one, 0 to 1, and how much the pill thins while it is long. At 0 a rigid pill that only slides At 1 a long elastic drop that takes a moment to gather data-pill-nav-orientation auto Axis the pill stretches along. Options: auto reads it from each move, the longer way between the two links; horizontal and vertical hold it to one axis whatever the move. data-pill-nav-spy 0 Scroll spy: the link whose section is on screen becomes the current one while the page scrolls. Options: 0 off, the current link is the one marked in the markup or clicked; 1 on, for links to anchors of the same page. -
Drive it from JavaScript
When something other than the pointer should move the pill, when the current link changes from your own code, or when the page reacts to the link the pill is on.const element = document.querySelector('[data-pill-nav]'); element.addEventListener('pillnav:change', (e) => { console.log(e.detail); }); const nav = PillNav.mount(element); // the bar auto() mounted, or a new one nav.go(2); // the pill travels to the third link, as under the pointer nav.leave(); // and comes back to the current link nav.select(1); // the second link becomes the current one element.addEventListener('pillnav:change', (e) => { console.log('current link', e.detail.from, 'to', e.detail.index); }); // When the bar leaves the page: PillNav.destroy(element); // removes the pill, the copy and the listeners
-
Fit it to your page
- Structure: one box marked
data-pill-navthat holds the links, each markeddata-pill-nav-link. The links need not be direct children: a list ofliworks. Without a single marked link, everya,buttonand[role="tab"]of the box is taken. - The current link is the one that carries
aria-current, any value butfalse:aria-current="page"on a site of several pages, which Webflow writes by itself on a link to the page being shown. With no current link the pill is hidden at rest and shows under the pointer. - What the script adds: two boxes at the end of the bar.
pill-nav-pillis the pill, laid over the links;pill-nav-overis a copy of the links in theinkcolour, laid over the pill and clipped to its shape on every frame. Neither takes the pointer, the keyboard or a screen reader: the copy isinertandaria-hidden, its links are out of the tab order. - What the script writes:
data-pill-nav-stateon the bar,livewhile the pill travels,idleat rest,emptywhen there is no link to sit on; andaria-currenton the links, moved to the link clicked or to the link the scroll spy reaches. The size and the place of the pill are inline styles on the pill itself, measured from the link, never on your elements. - A click makes the link current. The pill then comes back to it when the pointer leaves. On a site of several pages the page changes anyway; on tabs or anchors the bar keeps the choice.
- One setting for every bar of the page, without opening the file: declare
window.PillNavSettings = { color: '#d4ff3f', ink: '#15100c' }in a script placed beforepill-nav.js. The three levels, least specific first, areDEFAULTS, then that object, then adata-pill-nav-*attribute on the bar itself (data-pill-nav-color="#d4ff3f"), so one bar can still differ from the page-wide setting. - The two colours can also come from the stylesheet: without a setting in the attributes or in the settings block, the custom properties
--pill-nav-colorand--pill-nav-inkon the bar are read, thenDEFAULTS. The Embed writes its own copy ofDEFAULTSinto the settings block, so on a Webflow page the attribute is the way to change one bar. - A column of links needs nothing: with
orientationonautothe pill stretches along the longer way between the two links, down a column and along a row, and a bar that turns into a column on a phone, as the preview's does at 767px, changes axis by itself. - Scroll spy,
data-pill-nav-spy="1": every link whosehrefnames an anchor of the page (#stock) watches that section, and the section that crosses a line 40% down the window becomes the current link, markedaria-current="location". A click holds the spy until the scroll it started has stopped, so the pill goes straight to the clicked link instead of visiting every section on the way. - **From JavaScript, beyond
mount,autoanddestroy**: the api carrieselement,settings,links(),current()andshown()(the index of the current link and of the link the pill is on, -1 for none),go(index)which sends the pill to a link as the pointer would,leave()which sends it back,select(index)which makes a link current as a click would,refresh()which reads the settings, the links and the sizes again, andframes()andisRunning()for a test that wants to see the loop sleep at rest. Two events bubble from the bar:pillnav:movewhen the pill takes another link,pillnav:changewhen the current link changes. - Links that change after load are followed without a call: a link added, removed, reworded, or given another class is seen by the script, which makes its copy again and puts the pill back.
- **
destroy()leaves the bar as it was**: the two boxes removed, the listeners and the observers gone, the state attribute taken off, and the inlineposition: relativeremoved if the script set it.aria-currentstays where the last click put it: it is the page's state, not the effect's. - Touch: no hover on a finger, so nothing travels under it. A tap is a click: the tapped link becomes the current one and the pill travels to it with the same stretch.
- **
prefers-reduced-motion**: the pill jumps to its link, no travel and no spring, and it shows and goes without a fade. Turning the preference off brings the travel back without a reload. - The words of the markup are the preview's, and they ship as they are. Put the user's own words in their place whenever they want.
- On Webflow, add the fonts first. Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: the paste names them, and the Designer drops a font the site does not have.
- Structure: one box marked
-
Avoid the pitfalls
- The pill is drawn over the links, not under them. That is what lets the covered text change colour without a blend mode: the pill hides the original text and the copy is drawn over it. A link's own hover background is therefore hidden where the pill is, and a link colour change on hover only shows outside the pill.
- The script appends two boxes to the bar, so
:last-childno longer matches the last link. Write:last-of-type, or put a class on the link. They are out of the flow: a flex or a grid bar does not count them as items, and they take no gap. - The copy is laid out like the bar. It takes the bar's padding and its flex or grid values, and clones every child. A bar laid out by something else, links placed one by one with absolute positions for instance, gives a copy that does not fall on the originals, and the covered text doubles.
- **
overflow: hiddenon the bar clips the pill** whenpadmakes it reach past the bar's edge. A bar that scrolls sideways works: the pill is placed in the scrolled content and travels with it. - A transform that scales the bar breaks the measure: the boxes are read in screen pixels and written in the bar's own. Scale a parent of the bar's parent, or do not scale.
- **Settings handed to
mount()after the page loaded are dropped.**auto()mounts on DOM ready, andmount()on a bar that already carries the effect returns the existing one. A page-wide block goes inwindow.PillNavSettings, whichauto()reads itself; a bar already mounted takes new attributes withrefresh(). - Scroll spy needs sections taller than a sliver. The line is 1% of the window tall; a section that never crosses it, the last one of a short page for instance, never becomes current by scrolling alone.
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Pill nav is an animated navigation component for Webflow and vanilla JavaScript, with no library. Also called sliding pill navigation, sliding nav indicator or navbar sliding highlight.
A navigation bar whose highlight is alive. One pill sits on the current link. When the pointer moves over another link, the pill travels to it: the edge that leads leaves first and the edge that trails follows on a softer spring, so the pill stretches the way it goes, thins a little while it is long, then gathers itself and squashes lightly as it lands. The text takes a second colour exactly where the pill covers it, so a letter half under the pill is half in each colour, all the way through the travel. Leaving the bar sends the pill back to the current link, a click makes the clicked link the current one, and the keyboard focus moves the pill the same way. It works on a row of links and on a column, the axis of the stretch chosen from the move itself, and follows a resize, a late font or a link the page rewrites: every position is measured, none is kept. By a setting it marks the section on screen while a one-page site scrolls. No library: two boxes the script adds to the bar, a spring stepped by hand, a clip path. On touch a tap sends the pill to the tapped link; under reduced motion the pill jumps.
Updated
Questions
Give the box that holds the links the custom attribute data-pill-nav and each link data-pill-nav-link, in the Settings panel. Webflow already writes aria-current="page" on a link to the page being shown, and that is the link the pill rests on. Settings are more attributes on the bar, for instance data-pill-nav-color="#d4ff3f".
No. The script adds two boxes to the bar, steps a spring by hand and clips a copy of the links with clip-path, with no library and no build step. When the pill has landed the loop stops: no frame and no timer run at rest.
data-pill-nav-stiffness is the pull of the spring, 260 by default: lower gives a slow, heavy pill, and past 600 it snaps to the link almost at once. data-pill-nav-damping is the friction, 22 by default: under 14 the pill bounces several times before it rests, past 32 it glides in with no bounce.
Yes, with data-pill-nav-spy="1", which is off by default. Every link whose href names an anchor of the page watches that section, and the section that crosses a line 40% down the window becomes the current link, marked aria-current="location". A section that never crosses that line, the last one of a short page for instance, never becomes current by scrolling alone.
Yes. The keyboard focus moves the pill the same way the pointer does, and on touch a tap sends the pill to the tapped link. The copy of the links that carries the second text colour is inert and aria-hidden, and under prefers-reduced-motion the pill jumps to its link with no travel.