Skip to content
UI ComponentNavbarNavigationNew

Floating Island Navbar with Scroll Progress & Mobile Tab Bar

A floating glass 'island' navbar that shrinks as you scroll, with a scroll-progress ring around the logo, a sliding pill that follows the active section (scrollspy) and previews on hover, a notifications panel with unread badge and mark-all-read, and an account menu with online / away / busy status. On phones the links move into an app-style bottom tab bar with a sliding bubble.

  • Published 15 Oct 2026
  • 9 views
Floating Island Navbar with Scroll Progress & Mobile Tab Bar — preview

Overview

Floating Island Navbar with Scroll Progress & Mobile Tab Bar

Navigation that floats above it all — progress ring, scrollspy pill, notifications and an app-like tab bar on phones.


What's Included

  • Floating glass island that compacts after you start scrolling
  • Scroll-progress ring around the logo
  • Scrollspy: the pill slides to the section you're reading (and previews on hover)
  • Notifications panel with unread badge, mark-all-read and a live 'new' bell ring
  • Account menu with online / away / busy status
  • Panels unfold from the island with a clip-path animation
  • Mobile bottom tab bar with sliding active bubble

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Floating Island Navbar | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <nav class="island" id="island" aria-label="Main">
12      <a href="#home" class="brand" aria-label="Orbit — back to top">
13        <svg class="ring" viewBox="0 0 44 44" aria-hidden="true">
14          <circle class="ring-track" cx="22" cy="22" r="19" />
15          <circle class="ring-fill" id="ring-fill" cx="22" cy="22" r="19" />
16        </svg>
17        <span class="brand-dot" aria-hidden="true"></span>
18      </a>
19
20      <ul class="links" id="links">
21        <li><a href="#home" data-section="home">Home</a></li>
22        <li><a href="#features" data-section="features">Features</a></li>
23        <li><a href="#work" data-section="work">Work</a></li>
24        <li><a href="#pricing" data-section="pricing">Pricing</a></li>
25        <li><a href="#contact" data-section="contact">Contact</a></li>
26        <li class="pill" aria-hidden="true"></li>
27      </ul>
28
29      <div class="tools">
30        <div class="pop">
31          <button type="button" class="icon-btn" id="bell-btn" aria-label="Notifications" aria-expanded="false" aria-controls="bell-panel">
32            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.9 1.9 0 0 0 3.4 0" /></svg>
33            <span class="badge" id="badge">3</span>
34          </button>
35          <div class="panel" id="bell-panel" role="dialog" aria-label="Notifications">
36            <div class="panel-head">
37              <strong>Notifications</strong>
38              <button type="button" class="link-btn" id="mark-all">Mark all as read</button>
39            </div>
40            <ul class="notes" id="notes"></ul>
41          </div>
42        </div>
43
44        <div class="pop">
45          <button type="button" class="avatar-btn" id="avatar-btn" aria-label="Account menu" aria-expanded="false" aria-controls="avatar-panel">
46            <span class="avatar">AK</span>
47            <span class="status-dot" id="status-dot" data-status="online"></span>
48          </button>
49          <div class="panel" id="avatar-panel" role="menu" aria-label="Account">
50            <div class="profile">
51              <span class="avatar big">AK</span>
52              <div>
53                <strong>Ankul Kumar</strong>
54                <small>ankul@orbit.dev</small>
55              </div>
56            </div>
57            <div class="status-row" role="group" aria-label="Status">
58              <button type="button" data-status="online" aria-pressed="true"><i></i>Online</button>
59              <button type="button" data-status="away" aria-pressed="false"><i></i>Away</button>
60              <button type="button" data-status="dnd" aria-pressed="false"><i></i>Busy</button>
61            </div>
62            <a href="#" role="menuitem">👤 Profile</a>
63            <a href="#" role="menuitem">⚙️ Settings</a>
64            <a href="#" role="menuitem">💳 Billing <span class="tag">Pro</span></a>
65            <a href="#" role="menuitem">⌨️ Shortcuts <kbd>?</kbd></a>
66            <hr />
67            <a href="#" role="menuitem" class="danger">↩ Sign out</a>
68          </div>
69        </div>
70      </div>
71    </nav>
72
73    <!-- Mobile bottom tab bar -->
74    <nav class="tabbar" aria-label="Sections">
75      <span class="tab-bubble" aria-hidden="true"></span>
76      <a href="#home" data-section="home"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" /></svg><span>Home</span></a>
77      <a href="#features" data-section="features"><svg viewBox="0 0 24 24"><path d="M13 3L4 14h7l-1 7 9-11h-7z" /></svg><span>Features</span></a>
78      <a href="#work" data-section="work"><svg viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2" /><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg><span>Work</span></a>
79      <a href="#pricing" data-section="pricing"><svg viewBox="0 0 24 24"><path d="M20 12l-8 8-9-9V3h8z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg><span>Pricing</span></a>
80      <a href="#contact" data-section="contact"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></svg><span>Contact</span></a>
81    </nav>
82
83    <!-- Demo sections -->
84    <main>
85      <section id="home" class="sec" style="--c1: #7c5cff; --c2: #22d3ee">
86        <span class="eyebrow">Floating island navbar</span>
87        <h1>Navigation that<br />floats above it all.</h1>
88        <p>Scroll — the island shrinks, the ring around the logo fills and the active section follows you. Try the bell and the avatar.</p>
89      </section>
90      <section id="features" class="sec" style="--c1: #f43f5e; --c2: #f59e0b">
91        <h2>Features</h2>
92        <div class="cards"><div></div><div></div><div></div></div>
93      </section>
94      <section id="work" class="sec" style="--c1: #10b981; --c2: #3b82f6">
95        <h2>Work</h2>
96        <div class="cards"><div></div><div></div><div></div></div>
97      </section>
98      <section id="pricing" class="sec" style="--c1: #a855f7; --c2: #ec4899">
99        <h2>Pricing</h2>
100        <div class="cards"><div></div><div></div><div></div></div>
101      </section>
102      <section id="contact" class="sec" style="--c1: #06b6d4; --c2: #84cc16">
103        <h2>Contact</h2>
104        <div class="cards"><div></div><div></div><div></div></div>
105      </section>
106    </main>
107
108    <script src="script.js"></script>
109  </body>
110</html>

CSS

css
1/* Importing Google Fonts - Urbanist */
2@import url("https://fonts.googleapis.com/css2?family=Urbanist:wght@400;500;600;700;800;900&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Urbanist", sans-serif;
9}
10
11:root {
12  --bg: #07080c;
13  --glass: rgba(22, 24, 33, 0.72);
14  --glass-solid: rgba(16, 17, 24, 0.92);
15  --line: rgba(255, 255, 255, 0.1);
16  --text: #f3f4f8;
17  --muted: #9599ab;
18  --accent: #8b7cff;
19  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
20}
21
22html {
23  scroll-behavior: smooth;
24}
25
26body {
27  background: var(--bg);
28  color: var(--text);
29}
30
31a {
32  color: inherit;
33  text-decoration: none;
34}
35
36/* =========================================================
37   The island
38   ========================================================= */
39.island {
40  position: fixed;
41  top: 18px;
42  left: 50%;
43  z-index: 50;
44  display: flex;
45  align-items: center;
46  gap: 10px;
47  padding: 7px;
48  border: 1px solid var(--line);
49  border-radius: 999px;
50  background: var(--glass);
51  backdrop-filter: blur(18px) saturate(1.5);
52  box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.06);
53  transform: translateX(-50%);
54  transition: padding 0.4s var(--ease), gap 0.4s var(--ease), background 0.4s, top 0.4s var(--ease);
55}
56
57/* Compact once you start scrolling */
58.island.is-compact {
59  top: 12px;
60  gap: 6px;
61  padding: 5px;
62  background: var(--glass-solid);
63}
64
65/* Logo with a scroll-progress ring around it */
66.brand {
67  position: relative;
68  display: grid;
69  place-items: center;
70  width: 44px;
71  height: 44px;
72  flex-shrink: 0;
73}
74
75.ring {
76  position: absolute;
77  inset: 0;
78  transform: rotate(-90deg);
79}
80
81.ring circle {
82  fill: none;
83  stroke-width: 3;
84}
85
86.ring-track {
87  stroke: rgba(255, 255, 255, 0.08);
88}
89
90.ring-fill {
91  stroke: var(--accent);
92  stroke-linecap: round;
93  stroke-dasharray: 119.4;
94  stroke-dashoffset: calc(119.4 * (1 - var(--progress, 0)));
95}
96
97.brand-dot {
98  width: 22px;
99  height: 22px;
100  border-radius: 50%;
101  background: conic-gradient(from 200deg, #8b7cff, #22d3ee, #f472b6, #8b7cff);
102  animation: spin 8s linear infinite;
103}
104
105@keyframes spin {
106  to {
107    transform: rotate(360deg);
108  }
109}
110
111/* Links + sliding pill */
112.links {
113  position: relative;
114  display: flex;
115  list-style: none;
116}
117
118.links a {
119  position: relative;
120  z-index: 1;
121  display: block;
122  padding: 10px 16px;
123  border-radius: 999px;
124  color: var(--muted);
125  font-size: 0.95rem;
126  font-weight: 600;
127  transition: color 0.3s, padding 0.4s var(--ease);
128}
129
130.island.is-compact .links a {
131  padding: 8px 13px;
132}
133
134.links a:hover,
135.links a.is-active {
136  color: var(--text);
137}
138
139.links a:focus-visible,
140.icon-btn:focus-visible,
141.avatar-btn:focus-visible,
142.brand:focus-visible {
143  outline: 2px solid var(--accent);
144  outline-offset: 2px;
145  border-radius: 999px;
146}
147
148.pill {
149  position: absolute;
150  top: 0;
151  left: 0;
152  height: 100%;
153  width: var(--w, 0);
154  border-radius: 999px;
155  background: rgba(255, 255, 255, 0.1);
156  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
157  transform: translateX(var(--x, 0));
158  transition: transform 0.45s var(--ease), width 0.45s var(--ease);
159}
160
161/* Tools on the right */
162.tools {
163  display: flex;
164  align-items: center;
165  gap: 6px;
166}
167
168.pop {
169  position: relative;
170}
171
172.icon-btn,
173.avatar-btn {
174  position: relative;
175  display: grid;
176  place-items: center;
177  width: 44px;
178  height: 44px;
179  border: 0;
180  border-radius: 50%;
181  background: rgba(255, 255, 255, 0.06);
182  color: var(--text);
183  cursor: pointer;
184  transition: background 0.2s;
185}
186
187.icon-btn:hover,
188.icon-btn[aria-expanded="true"] {
189  background: rgba(255, 255, 255, 0.14);
190}
191
192.icon-btn svg,
193.tabbar svg {
194  width: 20px;
195  height: 20px;
196  fill: none;
197  stroke: currentColor;
198  stroke-width: 2;
199  stroke-linecap: round;
200  stroke-linejoin: round;
201}
202
203.icon-btn[aria-expanded="true"] svg,
204.icon-btn.ring-it svg {
205  animation: ringBell 0.6s ease;
206}
207
208@keyframes ringBell {
209  20% { transform: rotate(14deg); }
210  40% { transform: rotate(-12deg); }
211  60% { transform: rotate(8deg); }
212  80% { transform: rotate(-4deg); }
213}
214
215.badge {
216  position: absolute;
217  top: 6px;
218  right: 6px;
219  display: grid;
220  place-items: center;
221  min-width: 17px;
222  height: 17px;
223  padding: 0 4px;
224  border: 2px solid #15161f;
225  border-radius: 999px;
226  background: #f43f5e;
227  font-size: 0.65rem;
228  font-weight: 800;
229  transition: transform 0.3s var(--ease);
230}
231
232.badge.is-empty {
233  transform: scale(0);
234}
235
236.avatar-btn {
237  background: none;
238}
239
240.avatar {
241  display: grid;
242  place-items: center;
243  width: 38px;
244  height: 38px;
245  border-radius: 50%;
246  background: linear-gradient(135deg, #f472b6, #8b7cff);
247  font-size: 0.8rem;
248  font-weight: 800;
249}
250
251.avatar.big {
252  width: 46px;
253  height: 46px;
254  font-size: 0.95rem;
255}
256
257.status-dot {
258  position: absolute;
259  right: 3px;
260  bottom: 3px;
261  width: 12px;
262  height: 12px;
263  border: 2px solid #15161f;
264  border-radius: 50%;
265  background: #22c55e;
266}
267
268.status-dot[data-status="away"] { background: #f59e0b; }
269.status-dot[data-status="dnd"] { background: #f43f5e; }
270
271/* ---------- Dropdown panels (unfold from the island) ---------- */
272.panel {
273  position: absolute;
274  top: calc(100% + 16px);
275  right: -7px;
276  width: 340px;
277  padding: 10px;
278  border: 1px solid var(--line);
279  border-radius: 24px;
280  background: rgba(17, 18, 26, 0.97);
281  box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.85);
282  opacity: 0;
283  visibility: hidden;
284  transform: translateY(-12px) scale(0.92);
285  transform-origin: top right;
286  clip-path: inset(0 0 100% 0 round 24px);
287  transition: opacity 0.25s, transform 0.4s var(--ease), clip-path 0.45s var(--ease), visibility 0.4s;
288}
289
290.pop.is-open .panel {
291  opacity: 1;
292  visibility: visible;
293  transform: none;
294  clip-path: inset(0 0 0 0 round 24px);
295}
296
297.panel-head {
298  display: flex;
299  justify-content: space-between;
300  align-items: center;
301  padding: 8px 10px 12px;
302}
303
304.link-btn {
305  border: 0;
306  background: none;
307  color: var(--accent);
308  font-size: 0.85rem;
309  font-weight: 700;
310  cursor: pointer;
311}
312
313.notes {
314  display: grid;
315  gap: 2px;
316  list-style: none;
317}
318
319.note {
320  position: relative;
321  display: grid;
322  grid-template-columns: 38px 1fr;
323  gap: 12px;
324  padding: 10px 28px 10px 10px;
325  border-radius: 16px;
326  cursor: pointer;
327  transition: background 0.2s;
328}
329
330.note:hover {
331  background: rgba(255, 255, 255, 0.06);
332}
333
334.note .n-av {
335  display: grid;
336  place-items: center;
337  width: 38px;
338  height: 38px;
339  border-radius: 12px;
340  font-size: 1.1rem;
341  background: rgba(255, 255, 255, 0.08);
342}
343
344.note p {
345  color: var(--muted);
346  font-size: 0.88rem;
347  line-height: 1.4;
348}
349
350.note p b {
351  color: var(--text);
352}
353
354.note small {
355  color: #6b6f82;
356  font-size: 0.75rem;
357}
358
359.note.unread::after {
360  content: "";
361  position: absolute;
362  top: 50%;
363  right: 12px;
364  width: 8px;
365  height: 8px;
366  border-radius: 50%;
367  background: var(--accent);
368  transform: translateY(-50%);
369}
370
371.profile {
372  display: flex;
373  align-items: center;
374  gap: 12px;
375  padding: 8px 10px 14px;
376}
377
378.profile small {
379  display: block;
380  color: var(--muted);
381}
382
383.status-row {
384  display: grid;
385  grid-template-columns: repeat(3, 1fr);
386  gap: 4px;
387  margin: 0 4px 8px;
388  padding: 4px;
389  border-radius: 14px;
390  background: rgba(255, 255, 255, 0.05);
391}
392
393.status-row button {
394  display: flex;
395  align-items: center;
396  justify-content: center;
397  gap: 6px;
398  padding: 8px;
399  border: 0;
400  border-radius: 10px;
401  background: none;
402  color: var(--muted);
403  font-size: 0.82rem;
404  font-weight: 700;
405  cursor: pointer;
406}
407
408.status-row i {
409  width: 8px;
410  height: 8px;
411  border-radius: 50%;
412  background: #22c55e;
413}
414
415.status-row [data-status="away"] i { background: #f59e0b; }
416.status-row [data-status="dnd"] i { background: #f43f5e; }
417
418.status-row [aria-pressed="true"] {
419  background: rgba(255, 255, 255, 0.1);
420  color: var(--text);
421}
422
423#avatar-panel {
424  width: 280px;
425}
426
427#avatar-panel > a {
428  display: flex;
429  align-items: center;
430  gap: 10px;
431  padding: 11px 12px;
432  border-radius: 12px;
433  font-size: 0.93rem;
434  font-weight: 600;
435  transition: background 0.2s;
436}
437
438#avatar-panel > a:hover,
439#avatar-panel > a:focus-visible {
440  background: rgba(255, 255, 255, 0.07);
441  outline: none;
442}
443
444#avatar-panel .tag {
445  margin-left: auto;
446  padding: 2px 8px;
447  border-radius: 999px;
448  background: rgba(139, 124, 255, 0.2);
449  color: #c4bcff;
450  font-size: 0.72rem;
451}
452
453#avatar-panel kbd {
454  margin-left: auto;
455  padding: 1px 7px;
456  border: 1px solid var(--line);
457  border-radius: 6px;
458  color: var(--muted);
459  font-family: inherit;
460  font-size: 0.75rem;
461}
462
463#avatar-panel hr {
464  margin: 6px 8px;
465  border: 0;
466  border-top: 1px solid var(--line);
467}
468
469#avatar-panel .danger {
470  color: #fb7185;
471}
472
473/* =========================================================
474   Mobile tab bar
475   ========================================================= */
476.tabbar {
477  position: fixed;
478  left: 50%;
479  bottom: 14px;
480  z-index: 50;
481  display: none;
482  grid-template-columns: repeat(5, 1fr);
483  width: min(440px, calc(100% - 24px));
484  padding: 6px;
485  border: 1px solid var(--line);
486  border-radius: 24px;
487  background: var(--glass-solid);
488  backdrop-filter: blur(18px);
489  box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.9);
490  transform: translateX(-50%);
491}
492
493.tabbar a {
494  position: relative;
495  z-index: 1;
496  display: grid;
497  justify-items: center;
498  gap: 3px;
499  padding: 8px 0;
500  color: var(--muted);
501  font-size: 0.7rem;
502  font-weight: 700;
503  transition: color 0.3s;
504}
505
506.tabbar a.is-active {
507  color: var(--text);
508}
509
510.tab-bubble {
511  position: absolute;
512  top: 6px;
513  bottom: 6px;
514  left: 6px;
515  width: calc((100% - 12px) / 5);
516  border-radius: 18px;
517  background: rgba(139, 124, 255, 0.22);
518  transform: translateX(calc(var(--i, 0) * 100%));
519  transition: transform 0.45s var(--ease);
520}
521
522/* =========================================================
523   Demo sections
524   ========================================================= */
525.sec {
526  min-height: 92vh;
527  padding: 150px 24px 80px;
528  max-width: 1100px;
529  margin: 0 auto;
530  scroll-margin-top: 0;
531}
532
533.eyebrow {
534  color: var(--accent);
535  font-size: 0.85rem;
536  font-weight: 800;
537  letter-spacing: 0.16em;
538  text-transform: uppercase;
539}
540
541.sec h1,
542.sec h2 {
543  margin: 14px 0 18px;
544  font-size: clamp(2.6rem, 8vw, 5.6rem);
545  font-weight: 900;
546  line-height: 0.95;
547  letter-spacing: -0.04em;
548  background: linear-gradient(100deg, var(--c1), var(--c2));
549  -webkit-background-clip: text;
550  background-clip: text;
551  color: transparent;
552}
553
554.sec p {
555  max-width: 560px;
556  color: var(--muted);
557  font-size: 1.15rem;
558  line-height: 1.6;
559}
560
561.cards {
562  display: grid;
563  grid-template-columns: repeat(3, 1fr);
564  gap: 18px;
565  margin-top: 30px;
566}
567
568.cards div {
569  height: 280px;
570  border: 1px solid var(--line);
571  border-radius: 26px;
572  background:
573    radial-gradient(circle at 20% 0%, color-mix(in srgb, var(--c1) 35%, transparent), transparent 60%),
574    radial-gradient(circle at 100% 100%, color-mix(in srgb, var(--c2) 25%, transparent), transparent 60%),
575    #101219;
576}
577
578/* ---------- Responsive ---------- */
579@media (max-width: 760px) {
580  .links {
581    display: none;
582  }
583
584  .island {
585    left: 12px;
586    right: 12px;
587    justify-content: space-between;
588    transform: none;
589  }
590
591  .tabbar {
592    display: grid;
593  }
594
595  /* The island (fixed + backdrop-filter) becomes the panel's box */
596  .pop {
597    position: static;
598  }
599
600  .panel {
601    top: calc(100% + 12px);
602    left: 0;
603    right: 0;
604    width: auto !important;
605    transform-origin: top center;
606  }
607
608  .cards {
609    grid-template-columns: 1fr;
610  }
611
612  .sec {
613    padding-top: 120px;
614  }
615}
616
617@media (prefers-reduced-motion: reduce) {
618  html {
619    scroll-behavior: auto;
620  }
621
622  *,
623  *::before,
624  *::after {
625    animation: none !important;
626    transition: none !important;
627  }
628}

JavaScript

javascript
1// Edit these notifications freely
2const notifications = [
3  { icon: "🚀", text: "<b>Riya</b> deployed <b>orbit-web</b> to production", time: "2 min ago", unread: true },
4  { icon: "💬", text: "<b>Kabir</b> replied to your comment on <b>Landing v2</b>", time: "18 min ago", unread: true },
5  { icon: "⭐", text: "Your project got <b>12 new stars</b> today", time: "1 h ago", unread: true },
6  { icon: "🧾", text: "Invoice <b>#1043</b> was paid", time: "Yesterday", unread: false },
7];
8
9const island = document.getElementById("island");
10const links = [...document.querySelectorAll("#links a")];
11const pill = document.querySelector("#links .pill");
12const ringFill = document.getElementById("ring-fill");
13const tabLinks = [...document.querySelectorAll(".tabbar a")];
14const tabBubble = document.querySelector(".tab-bubble");
15const sections = links.map((a) => document.getElementById(a.dataset.section));
16let activeId = "home";
17
18// ---------- Sliding pill ----------
19const movePill = (link) => {
20  pill.style.setProperty("--x", `${link.offsetLeft}px`);
21  pill.style.setProperty("--w", `${link.offsetWidth}px`);
22};
23const activeLink = () => links.find((a) => a.dataset.section === activeId);
24
25links.forEach((a) => {
26  a.addEventListener("pointerenter", () => movePill(a)); // preview on hover
27});
28document.getElementById("links").addEventListener("pointerleave", () => movePill(activeLink()));
29
30const setActive = (id) => {
31  if (id === activeId && pill.style.getPropertyValue("--w")) return;
32  activeId = id;
33  links.forEach((a) => {
34    const on = a.dataset.section === id;
35    a.classList.toggle("is-active", on);
36    on ? a.setAttribute("aria-current", "true") : a.removeAttribute("aria-current");
37  });
38  tabLinks.forEach((a, i) => {
39    const on = a.dataset.section === id;
40    a.classList.toggle("is-active", on);
41    if (on) tabBubble.style.setProperty("--i", i);
42  });
43  movePill(activeLink());
44};
45
46// ---------- Scroll: compact island, progress ring, scrollspy ----------
47let ticking = false;
48const onScroll = () => {
49  ticking = false;
50  const max = document.documentElement.scrollHeight - innerHeight;
51  const progress = max > 0 ? scrollY / max : 0;
52  ringFill.style.setProperty("--progress", progress.toFixed(4));
53  island.classList.toggle("is-compact", scrollY > 40);
54
55  // The section whose top has passed 40% of the screen is the active one
56  const line = innerHeight * 0.4;
57  let current = sections[0].id;
58  for (const s of sections) if (s.getBoundingClientRect().top <= line) current = s.id;
59  if (progress > 0.995) current = sections.at(-1).id;
60  setActive(current);
61};
62window.addEventListener("scroll", () => {
63  if (!ticking) {
64    ticking = true;
65    requestAnimationFrame(onScroll);
66  }
67}, { passive: true });
68// Re-measure after the compact transition / font load / resize
69island.addEventListener("transitionend", () => movePill(activeLink()));
70window.addEventListener("resize", onScroll);
71document.fonts?.ready.then(() => movePill(activeLink()));
72
73// ---------- Dropdowns ----------
74const pops = [...document.querySelectorAll(".pop")];
75const closePops = (except) =>
76  pops.forEach((p) => {
77    if (p === except) return;
78    p.classList.remove("is-open");
79    p.querySelector("button").setAttribute("aria-expanded", "false");
80  });
81
82pops.forEach((pop) => {
83  const btn = pop.querySelector("button");
84  btn.addEventListener("click", (e) => {
85    e.stopPropagation();
86    const open = !pop.classList.contains("is-open");
87    closePops(pop);
88    pop.classList.toggle("is-open", open);
89    btn.setAttribute("aria-expanded", open);
90  });
91  pop.querySelector(".panel").addEventListener("click", (e) => e.stopPropagation());
92});
93document.addEventListener("click", () => closePops());
94document.addEventListener("keydown", (e) => {
95  if (e.key !== "Escape") return;
96  const open = pops.find((p) => p.classList.contains("is-open"));
97  if (open) {
98    closePops();
99    open.querySelector("button").focus();
100  }
101});
102
103// ---------- Notifications ----------
104const notesEl = document.getElementById("notes");
105const badge = document.getElementById("badge");
106const bellBtn = document.getElementById("bell-btn");
107
108const renderNotes = () => {
109  notesEl.innerHTML = notifications
110    .map((n, i) => `<li class="note ${n.unread ? "unread" : ""}" data-i="${i}">
111      <span class="n-av" aria-hidden="true">${n.icon}</span>
112      <div><p>${n.text}</p><small>${n.time}</small></div></li>`)
113    .join("");
114  const unread = notifications.filter((n) => n.unread).length;
115  badge.textContent = unread;
116  badge.classList.toggle("is-empty", unread === 0);
117  bellBtn.setAttribute("aria-label", unread ? `Notifications, ${unread} unread` : "Notifications");
118};
119
120notesEl.addEventListener("click", (e) => {
121  const li = e.target.closest(".note");
122  if (!li) return;
123  notifications[li.dataset.i].unread = false;
124  renderNotes();
125});
126document.getElementById("mark-all").addEventListener("click", () => {
127  notifications.forEach((n) => (n.unread = false));
128  renderNotes();
129});
130
131// A new notification "arrives" a few seconds after load
132setTimeout(() => {
133  notifications.unshift({ icon: "🎉", text: "<b>Welcome!</b> Your workspace is ready.", time: "Just now", unread: true });
134  renderNotes();
135  bellBtn.classList.remove("ring-it");
136  void bellBtn.offsetWidth;
137  bellBtn.classList.add("ring-it");
138}, 4000);
139
140// ---------- Status (online / away / busy) ----------
141const statusDot = document.getElementById("status-dot");
142document.querySelectorAll(".status-row button").forEach((b) => {
143  b.addEventListener("click", () => {
144    document.querySelectorAll(".status-row button").forEach((x) => x.setAttribute("aria-pressed", x === b));
145    statusDot.dataset.status = b.dataset.status;
146  });
147});
148
149renderNotes();
150setActive("home");
151onScroll();

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • Floating glass island that compacts after you start scrolling

  • Scroll-progress ring around the logo

  • Scrollspy: the pill slides to the section you're reading (and previews on hover)

  • Notifications panel with unread badge, mark-all-read and a live 'new' bell ring

  • Account menu with online / away / busy status

  • Panels unfold from the island with a clip-path animation

  • Mobile bottom tab bar with sliding active bubble

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

On scroll, script.js checks which section's top has passed 40% of the screen and moves the pill to its link. Add data-section to a link and give the section the same id.

Edit the notifications array at the top of script.js (icon, text, time, unread). The badge count updates automatically.

Yes — delete the .tabbar nav, or keep the desktop links visible on phones by removing the .links { display: none } rule in the mobile media query.

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.

Floating Island Navbar with Scrollspy (HTML CSS JS) | MetaSkillGroup