Bottom Mobile Tab Bar
A five-tab bottom bar with an active state, an unread badge and safe-area padding for notched phones.
6 frameworksBeginner
A floating icon dock whose items swell under the pointer, the neighbours lifting less than the one you are on.
<nav class="dock" aria-label="Dock">
<ul class="dock__list">
<li class="dock__item">
<a class="dock__link" href="/files" aria-current="page">
<svg class="dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4 7a2 2 0 0 1 2-2h3l2 2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />
</svg>
<!-- The label is the accessible name. It is clipped, not hidden: -->
<!-- display:none or [hidden] would take it out of the a11y tree too. -->
<span class="dock__label">Files</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/mail">
<svg class="dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
<span class="dock__label">Mail</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/calendar">
<svg class="dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
<span class="dock__label">Calendar</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/photos">
<svg class="dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="m4 17 5-5 4 4 3-2 4 4" />
</svg>
<span class="dock__label">Photos</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/settings">
<svg class="dock__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="3" />
<path d="M12 3v2M12 19v2M4.2 7.5l1.7 1M18.1 15.5l1.7 1M4.2 16.5l1.7-1M18.1 8.5l1.7-1" />
</svg>
<span class="dock__label">Settings</span>
</a>
</li>
</ul>
</nav>
<script>
(function () {
// Distance in px from the pointer at which magnification has faded to
// nothing, and the scale of the item directly under it. RANGE wider than
// one item is what makes the NEIGHBOURS lift too - that gradient is the
// whole effect. Drop RANGE to ~48 and only the hovered item moves, which
// reads as a hover state, not a dock.
var RANGE = 110;
var MAX_SCALE = 1.6;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.dock').forEach(function (dock) {
var links = Array.prototype.slice.call(dock.querySelectorAll('.dock__link'));
function reset() {
links.forEach(function (link) {
link.style.removeProperty('--dock-scale');
});
}
dock.addEventListener('mousemove', function (event) {
// Bail before writing anything: the CSS below also forces scale(1)
// under reduced motion, but leaving stale inline vars behind would
// mean the dock re-magnifies the moment the media query flips back.
if (reduce.matches) return;
links.forEach(function (link) {
var rect = link.getBoundingClientRect();
var centre = rect.left + rect.width / 2;
var distance = Math.abs(event.clientX - centre);
// Linear falloff: 1 under the pointer, 0 at RANGE and beyond.
var falloff = Math.max(0, 1 - distance / RANGE);
link.style.setProperty('--dock-scale', String(1 + (MAX_SCALE - 1) * falloff));
});
});
dock.addEventListener('mouseleave', reset);
reduce.addEventListener('change', function () {
if (reduce.matches) reset();
});
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly DockItem[] | DEFAULT_ITEMS | The array of items to render. |
onSelect | (id: string) => void | - | Fired with the menu item the user chose. |
ariaLabel | string | 'Dock' | The button's accessible name. Required for icon-only buttons. |
range | number | 110 | How far the magnification reaches, in pixels either side of the pointer. |
maxScale | number | 1.6 | How large the icon under the pointer grows, as a multiplier. |
className | string | - | Additional classes merged onto the root element. |
The effect is not "scale the hovered icon" - that reads as an ordinary hover state. It is a *gradient*: every item measures its own centre against the pointer and scales by a linear falloff, so the item under the cursor hits `MAX_SCALE` and its neighbours land somewhere between that and 1. `RANGE` is what makes or breaks it. At 110px it spans two or three icons and you get the wave; drop it near one icon width and the dock goes back to being a row of buttons. `transform-origin: bottom` matters just as much - anchored anywhere else the icons grow *through* the dock floor instead of rising out of it. Each icon carries a clipped `<span>` as its accessible name, deliberately not a `title` or a tooltip: magnification is a mouse affordance and communicates nothing to a screen reader, so the name has to live in the markup. Reduced motion is handled twice, and both halves are needed - the script bails before writing `--dock-scale`, and the CSS forces `transform: none` so a stale inline value from before the preference flipped cannot resurrect the effect. The end state is every icon at its true size, which is a dock, not a broken one.