Introduction
This navigation bar has no icon library, no image assets, and no per-item CSS. Five icons come from one inline SVG sprite, a circular indicator slides to the active item because a single custom property changes, and the curved notch that blends the circle into the bar is two pseudo-elements and two box-shadows. Add a sixth item and nothing in the stylesheet needs to change.
The original pattern animates the indicator with one nth-child rule per item, loads icons from an external web-component script, and tracks the active state with a class. The interesting problem is how much of that you can replace with plain CSS arithmetic: the indicator’s position is just an index multiplied by an item width, and the active state is better stored in an attribute that assistive technology already understands.
How to build icons from an inline SVG sprite with symbol and use — how a grid and a clamp() token size the whole bar — how one --active-index variable replaces every nth-child rule — how a zero-blur box-shadow carves a concave curve — and why the first and last icons need side padding derived from the curve size.
Step 1 — Define Once with symbol, Reuse with use
An SVG sprite is a hidden SVG element that holds each icon as a <symbol> with its own viewBox. Any other SVG on the page can then draw it with <use href="#icon-home" />. There is no network request, no flash of missing icons, and no dependency that can fail to load. The sprite itself is given zero width and height and aria-hidden, so it takes no space and is ignored by screen readers.
<svg width="0" height="0" style="position: absolute" aria-hidden="true">
<symbol id="icon-home" viewBox="0 0 24 24">
<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</symbol>
<!-- profile, message, photos, settings ... -->
</svg>
<a class="nav__link" href="#home" aria-label="Home">
<svg class="nav__icon" aria-hidden="true"><use href="#icon-home" /></svg>
</a>The symbols contain only paths and circles with no colors. All styling lives on the .nav__icon class: fill: none, stroke: currentColor, a stroke width, and round caps and joins. Because the stroke is currentColor, an icon takes whatever text color its link has, which means the active state only has to change color and every icon follows.
.nav__icon {
width: 1.6rem;
height: 1.6rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}With no visible label, a link has nothing for a screen reader to announce. Each link carries an aria-label, and the decorative SVG inside it is marked aria-hidden so the icon is not read twice.
Step 2 — Every Dimension Comes from --item-size
The bar’s height, each item’s width, the indicator’s diameter, and the icon lift are all derived from one token. It uses clamp(3rem, 16vw, 4.5rem), so the nav is a comfortable 72px on desktop and shrinks smoothly on narrow phones instead of overflowing a fixed 400px container.
:root {
--item-size: clamp(3rem, 16vw, 4.5rem);
--nav-radius: 0.75rem;
--ring-width: 6px;
--curve-size: 1.25rem;
--lift: calc(var(--item-size) * -0.45);
}
.nav__list {
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--item-size);
}A grid with grid-auto-flow: column and a fixed grid-auto-columns gives every item exactly the same width without setting a width on any item. That guarantee matters, because the indicator will be positioned by multiplying one item width by an index. If the items could vary in width, the math would break.
Step 3 — translateX(calc(var(--active-index) * 100%))
The indicator is an absolutely positioned circle the same size as one item. It starts over the first item, and its transform is its own width multiplied by the active index. In translateX, a percentage refers to the element’s own width, so 100% is exactly one item, and the index turns that into 0, 1, 2, and so on.
.nav { --active-index: 0; }
.nav__indicator {
position: absolute;
top: calc(var(--item-size) * -0.5);
left: var(--nav-padding-x);
width: var(--item-size);
height: var(--item-size);
background: var(--color-accent);
border: var(--ring-width) solid var(--color-page);
border-radius: 50%;
pointer-events: none;
transform: translateX(calc(var(--active-index) * 100%));
transition: transform 500ms cubic-bezier(0.34, 1.3, 0.64, 1);
}Placing the circle at top: -50% of the item size puts its center exactly on the bar’s top edge, so half the circle rises above the bar. The border uses the page background color, which is what creates the cut-out ring that separates the circle from the bar. The easing curve has a y-value above 1, so the circle overshoots slightly and settles, which makes the move feel springy without any JavaScript animation.
Because the position is a pure function of one number, there is nothing to keep in sync. Change --active-index and the indicator, and only the indicator, moves. Five items or fifty, the stylesheet is the same.
Step 4 — Two Pseudo-Elements and Two Shadows
The signature of this nav is the smooth concave fillet where the circle meets the bar. CSS has no inverted border-radius, so it is faked. Each side gets a small transparent square with exactly one rounded top corner. A box-shadow in the page color, with zero blur and an upward offset, is then drawn from it. Box-shadows are never painted underneath the element’s own box, so only the crescent that falls outside the rounded corner shows, and that crescent is the concave curve.
.nav__indicator::before,
.nav__indicator::after {
content: "";
position: absolute;
top: 50%;
width: var(--curve-size);
height: var(--curve-size);
}
.nav__indicator::before {
left: calc(var(--curve-size) * -1 - 2px);
border-top-right-radius: var(--curve-size);
box-shadow: 1px calc(var(--curve-size) / -2) 0 0 var(--color-page);
}
.nav__indicator::after {
right: calc(var(--curve-size) * -1 - 2px);
border-top-left-radius: var(--curve-size);
box-shadow: -1px calc(var(--curve-size) / -2) 0 0 var(--color-page);
}The two pseudo-elements are mirror images: the left one rounds its top-right corner and the right one rounds its top-left. They sit at the vertical middle of the circle, which is the bar’s top edge. The 2px offset tucks each one slightly under the circle’s ring so there is no hairline gap, and the shadow’s vertical offset is half the curve size so the fillet lands flush with the bar. Because both pseudo-elements are children of the indicator, they travel with it for free.
Step 5 — aria-current, a Delegated Listener, and Side Padding
The active item is marked with aria-current="page" rather than a class. Assistive technology announces it, and the CSS reads the same attribute to lift the icon and recolor it. JavaScript has one job: when a link is clicked, move the attribute and write the link’s index into --active-index. A single listener on the nav handles every item through event delegation.
const nav = document.querySelector(".nav");
const links = [...nav.querySelectorAll(".nav__link")];
const setCurrent = (link) => {
links.forEach((item) => {
item === link
? item.setAttribute("aria-current", "page")
: item.removeAttribute("aria-current");
});
nav.style.setProperty("--active-index", links.indexOf(link));
};
// Sync with whichever link is marked current on load.
setCurrent(links.find((link) => link.hasAttribute("aria-current")) ?? links[0]);
nav.addEventListener("click", (event) => {
const link = event.target.closest(".nav__link");
if (!link) return;
event.preventDefault(); // Demo only: remove with real routes.
setCurrent(link);
});.nav__link[aria-current="page"] {
color: var(--color-icon-active);
}
.nav__link[aria-current="page"] .nav__icon {
transform: translateY(var(--lift));
}The concave curves reach about 16px past the circle on each side. With only 12px of side padding on the bar, the curve on the first and last item poked outside the bar and ran into its rounded corner. The fix is to derive the padding from the tokens: --nav-padding-x is --curve-size plus --nav-radius, so the overhang and the corner radius always fit, however you tune them.
Two more details keep this accessible. Links have a visible :focus-visible outline, and under prefers-reduced-motion the icon and indicator transitions are cut to a near-zero duration so the state still changes but nothing slides.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --item-size | clamp(3rem, 16vw, 4.5rem) | Sets bar height, item width, and indicator size, and scales down on small screens |
| --ring-width | 6px | Thickness of the page-colored ring that separates the circle from the bar |
| --curve-size | 1.25rem | Radius of the concave fillets; larger values give a softer, wider notch |
| --nav-padding-x | curve + radius | Side padding that keeps the first and last curves inside the bar |
| --lift | item-size * -0.45 | How far the active icon rises into the circle |
| easing curve | 0.34, 1.3, 0.64, 1 | A y-value above 1 makes the indicator overshoot and settle |
Full Source Code
The whole component is a single self-contained HTML file — markup, styles, sprite, and script, with no build step and no external requests at all.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/floating-indicator-nav.html