Skip to content

Island Nav

Dynamic island navbar: a dark pill that spring-morphs between links, a now-playing mini player, search and a phone menu.

  • Navbars
  • Music & audio
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. The morph is a rAF spring on width and height; outgoing content uses the inert attribute.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/island-nav.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/navbars/island-nav.tsx
1/**
2 * IslandNav, a "dynamic island" for a music app: one dark pill at the top of
3 * the page that morphs between states. Links by default, a now-playing mini
4 * player when you tap the track, a search field with suggestions, and (on
5 * phones) a tall menu. Width, height and corner radius are driven by an
6 * in-file spring integrator so every change of shape settles with a small,
7 * physical overshoot while the contents cross-fade inside it.
8 *
9 * Props: brand, links, current, track ({ title, artist, album, duration
10 * seconds, cover colours }), suggestions, defaultPlaying.
11 * CSS vars: --fd-isl-bg, --fd-isl-ink, --fd-isl-accent.
12 *
13 * Browser support: all evergreen browsers.
14 * Accessibility: the island is a <nav>; every trigger has aria-expanded and
15 * aria-controls; Escape returns to links and restores focus; clicking
16 * outside also closes. Play/pause is a toggle button with aria-pressed; the
17 * progress is a labelled slider (←/→ seek 5 s).
18 * Reduced motion: the island snaps to its new size, no equalizer animation.
19 */
20import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
21import type { KeyboardEvent as ReactKeyboardEvent } from "react";
22
23type View = "links" | "player" | "search" | "menu";
24export interface IslTrack { title: string; artist: string; album: string; duration: number; cover: { bg: string; ink: string; word: string } }
25export interface IslandNavProps {
26 brand?: string;
27 links?: { label: string; href: string }[];
28 current?: string;
29 track?: IslTrack;
30 suggestions?: { label: string; kind: string }[];
31 defaultPlaying?: boolean;
32 className?: string;
33}
34
35const LINKS = [
36 { label: "Listen now", href: "#listen" },
37 { label: "Browse", href: "#browse" },
38 { label: "Radio", href: "#radio" },
39 { label: "Library", href: "#library" },
40];
41const TRACK: IslTrack = { title: "Harbour Lights (Night Version)", artist: "Maren Solvik", album: "Low Tide", duration: 254, cover: { bg: "#e8ff3a", ink: "#0a0a0a", word: "LOW TIDE" } };
42const SUGGEST = [
43 { label: "Maren Solvik", kind: "Artist · 212K monthly listeners" },
44 { label: "Low Tide", kind: "Album · 2026 · 11 songs" },
45 { label: "Night ferry, Bergen to Hirtshals", kind: "Playlist · 3 h 12 min" },
46 { label: "Slow Nordic jazz", kind: "Station" },
47];
48
49const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
50
51function Cover({ t, size }: { t: IslTrack; size: number }) {
52 return (
53 <span className="fd-isl-cover" style={{ width: size, height: size, background: t.cover.bg, color: t.cover.ink }} aria-hidden="true">
54 <svg viewBox="0 0 40 40" width={size} height={size}>
55 <circle cx="33" cy="8" r="3.6" fill="currentColor" />
56 <path d="M0 30c6-4 10-4 14 0s8 4 14 0 8-4 12 0v10H0z" fill="currentColor" />
57 <path d="M0 24c6-4 10-4 14 0s8 4 14 0 8-4 12 0" fill="none" stroke="currentColor" strokeWidth="1.5" />
58 </svg>
59 {size >= 56 && <b>{t.cover.word}</b>}
60 </span>
61 );
62}
63
64function Eq({ on }: { on: boolean }) {
65 return <span className="fd-isl-eq" data-on={on ? "" : undefined} aria-hidden="true"><i /><i /><i /><i /></span>;
66}
67
68export function IslandNav({
69 brand = "fathom",
70 links = LINKS,
71 current = "#listen",
72 track = TRACK,
73 suggestions = SUGGEST,
74 defaultPlaying = false,
75 className,
76}: IslandNavProps) {
77 const uid = useId();
78 const [view, setView] = useState<View>("links");
79 const [prev, setPrev] = useState<View | null>(null);
80 const [playing, setPlaying] = useState(defaultPlaying);
81 const [t, setT] = useState(97);
82 const [q, setQ] = useState("");
83 const island = useRef<HTMLDivElement>(null);
84 const content = useRef<HTMLDivElement>(null);
85 const trigger = useRef<HTMLElement | null>(null);
86 const spring = useRef({ w: 0, h: 0, vw: 0, vh: 0, tw: 0, th: 0, raf: 0, init: false });
87
88 const go = (v: View, from?: HTMLElement | null) => {
89 if (v === view) return;
90 if (v !== "links") trigger.current = from ?? (document.activeElement as HTMLElement);
91 setPrev(view);
92 setView(v);
93 };
94 const back = () => {
95 go("links");
96 window.setTimeout(() => trigger.current?.focus(), 30);
97 };
98
99 // Spring morph of width / height / radius.
100 const apply = () => {
101 const el = island.current, s = spring.current;
102 if (!el) return;
103 el.style.width = `${s.w}px`;
104 el.style.height = `${s.h}px`;
105 el.style.borderRadius = `${Math.min(s.h / 2, 30)}px`;
106 };
107 useLayoutEffect(() => {
108 const c = content.current, s = spring.current;
109 if (!c) return;
110 const measure = () => {
111 s.tw = c.offsetWidth;
112 s.th = c.offsetHeight;
113 const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
114 if (!s.init || reduce) {
115 s.init = true;
116 s.w = s.tw;
117 s.h = s.th;
118 s.vw = s.vh = 0;
119 apply();
120 return;
121 }
122 if (s.raf) return;
123 let last = performance.now();
124 const step = (now: number) => {
125 const dt = Math.min(0.032, (now - last) / 1000 || 0.016);
126 last = now;
127 const k = 380, d = 27;
128 s.vw += (-k * (s.w - s.tw) - d * s.vw) * dt;
129 s.vh += (-k * (s.h - s.th) - d * s.vh) * dt;
130 s.w += s.vw * dt;
131 s.h += s.vh * dt;
132 apply();
133 if (Math.abs(s.w - s.tw) < 0.3 && Math.abs(s.h - s.th) < 0.3 && Math.abs(s.vw) + Math.abs(s.vh) < 2) {
134 s.w = s.tw; s.h = s.th; s.vw = s.vh = 0;
135 apply();
136 s.raf = 0;
137 return;
138 }
139 s.raf = requestAnimationFrame(step);
140 };
141 s.raf = requestAnimationFrame(step);
142 };
143 measure();
144 const ro = new ResizeObserver(measure);
145 ro.observe(c);
146 return () => ro.disconnect();
147 }, [view]);
148 useEffect(() => () => cancelAnimationFrame(spring.current.raf), []);
149
150 // Drop the outgoing layer once it has faded.
151 useEffect(() => {
152 if (!prev) return;
153 const tm = window.setTimeout(() => setPrev(null), 200);
154 return () => window.clearTimeout(tm);
155 }, [prev, view]);
156
157 // Escape + outside click.
158 useEffect(() => {
159 if (view === "links") return;
160 const onKey = (e: KeyboardEvent) => e.key === "Escape" && back();
161 const onDown = (e: PointerEvent) => !island.current?.contains(e.target as Node) && go("links");
162 document.addEventListener("keydown", onKey);
163 document.addEventListener("pointerdown", onDown);
164 if (view === "search") window.setTimeout(() => island.current?.querySelector<HTMLInputElement>("input")?.focus(), 60);
165 else window.setTimeout(() => island.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus(), 60);
166 return () => {
167 document.removeEventListener("keydown", onKey);
168 document.removeEventListener("pointerdown", onDown);
169 };
170 }, [view]); // eslint-disable-line react-hooks/exhaustive-deps
171
172 // Playback clock.
173 useEffect(() => {
174 if (!playing) return;
175 const iv = window.setInterval(() => setT((x) => (x + 1 >= track.duration ? 0 : x + 1)), 1000);
176 return () => window.clearInterval(iv);
177 }, [playing, track.duration]);
178
179 const seekKey = (e: ReactKeyboardEvent) => {
180 if (e.key === "ArrowRight") { e.preventDefault(); setT((x) => Math.min(track.duration, x + 5)); }
181 if (e.key === "ArrowLeft") { e.preventDefault(); setT((x) => Math.max(0, x - 5)); }
182 };
183
184 const filtered = q.trim() ? suggestions.filter((s) => (s.label + s.kind).toLowerCase().includes(q.trim().toLowerCase())) : suggestions;
185 const playerId = `${uid}-player`, searchId = `${uid}-search`, menuId = `${uid}-menu`;
186 const pct = (t / track.duration) * 100;
187
188 const playBtn = (big?: boolean) => (
189 <button type="button" className={`fd-isl-play${big ? " fd-isl-play-big" : ""}`} aria-pressed={playing} aria-label={playing ? "Pause" : "Play"} data-autofocus={big ? "" : undefined} onClick={() => setPlaying((p) => !p)}>
190 <svg width={big ? 22 : 14} height={big ? 22 : 14} viewBox="0 0 20 20" aria-hidden="true">
191 {playing ? <path d="M6 4h3v12H6zM11 4h3v12h-3z" fill="currentColor" /> : <path d="M6.5 3.8v12.4L16 10z" fill="currentColor" />}
192 </svg>
193 </button>
194 );
195
196 const render = (v: View) => {
197 if (v === "links")
198 return (
199 <div className="fd-isl-links">
200 <a href="#top" className="fd-isl-logo" aria-label={`${brand} home`}>
201 <svg width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><path d="M2 13c3-5 6-5 9 0s6 5 9 0" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" /><circle cx="11" cy="5" r="2" fill="var(--fd-isl-accent)" /></svg>
202 </a>
203 <ul className="fd-isl-ul">
204 {links.map((l) => <li key={l.href}><a href={l.href} className="fd-isl-link" aria-current={l.href === current ? "page" : undefined}>{l.label}</a></li>)}
205 </ul>
206 <span className="fd-isl-sep" aria-hidden="true" />
207 <button type="button" className="fd-isl-mini" aria-expanded={false} aria-controls={playerId} onClick={(e) => go("player", e.currentTarget)}>
208 <Cover t={track} size={26} />
209 <span className="fd-isl-minit">{track.title}</span>
210 <Eq on={playing} />
211 <span className="fd-isl-sr">, now {playing ? "playing" : "paused"}. Open player</span>
212 </button>
213 <button type="button" className="fd-isl-ic" aria-label="Search" aria-expanded={false} aria-controls={searchId} onClick={(e) => go("search", e.currentTarget)}>
214 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="8" cy="8" r="5.3" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M12 12l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
215 </button>
216 <button type="button" className="fd-isl-ic fd-isl-menubtn" aria-label="Menu" aria-expanded={false} aria-controls={menuId} onClick={(e) => go("menu", e.currentTarget)}>
217 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M3 6.5h12M3 11.5h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
218 </button>
219 </div>
220 );
221 if (v === "player")
222 return (
223 <div id={playerId} className="fd-isl-player" role="group" aria-label="Now playing">
224 <div className="fd-isl-ptop">
225 <Cover t={track} size={64} />
226 <div className="fd-isl-pmeta">
227 <b>{track.title}</b>
228 <span>{track.artist} · {track.album}</span>
229 </div>
230 <button type="button" className="fd-isl-ic fd-isl-close" aria-label="Collapse player" aria-expanded={true} onClick={back}>
231 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
232 </button>
233 </div>
234 <div className="fd-isl-prog">
235 <span>{fmt(t)}</span>
236 <div className="fd-isl-bar" role="slider" tabIndex={0} aria-label="Seek" aria-valuemin={0} aria-valuemax={track.duration} aria-valuenow={t} aria-valuetext={`${fmt(t)} of ${fmt(track.duration)}`} onKeyDown={seekKey}
237 onPointerDown={(e) => {
238 const r = e.currentTarget.getBoundingClientRect();
239 setT(Math.round(((e.clientX - r.left) / r.width) * track.duration));
240 }}>
241 <i style={{ width: `${pct}%` }} />
242 <b style={{ left: `${pct}%` }} />
243 </div>
244 <span>−{fmt(track.duration - t)}</span>
245 </div>
246 <div className="fd-isl-ctrls">
247 <button type="button" className="fd-isl-ic" aria-label="Shuffle" aria-pressed={false}>
248 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2.5 5h2.8c1.6 0 2.6.8 3.4 2.2l1.6 3.6c.8 1.4 1.8 2.2 3.4 2.2h1.8M2.5 13h2.8c1 0 1.8-.3 2.4-.9M10.4 5.9c.6-.6 1.4-.9 2.3-.9h1.8M13 3l2.5 2L13 7M13 11l2.5 2-2.5 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
249 </button>
250 <button type="button" className="fd-isl-ic" aria-label="Previous" onClick={() => setT(0)}>
251 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M15 4.5v11L7 10zM5 4.5v11" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /></svg>
252 </button>
253 {playBtn(true)}
254 <button type="button" className="fd-isl-ic" aria-label="Next" onClick={() => setT(0)}>
255 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 4.5v11l8-5.5zM15 4.5v11" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /></svg>
256 </button>
257 <button type="button" className="fd-isl-ic" aria-label="Lyrics">
258 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M3 4.5h12a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H8l-3.5 2.5V13H3a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5zM5.5 7.5h7M5.5 10h4.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
259 </button>
260 </div>
261 </div>
262 );
263 if (v === "search")
264 return (
265 <div id={searchId} className="fd-isl-search" role="search">
266 <div className="fd-isl-field">
267 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="8" cy="8" r="5.3" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M12 12l4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
268 <label htmlFor={`${uid}-q`} className="fd-isl-sr">Search songs, artists and stations</label>
269 <input id={`${uid}-q`} value={q} onChange={(e) => setQ(e.target.value)} placeholder="Songs, artists, stations" autoComplete="off" spellCheck={false} />
270 <button type="button" className="fd-isl-cancel" onClick={back}>Cancel</button>
271 </div>
272 <ul className="fd-isl-res" aria-label="Suggestions">
273 {filtered.map((s) => (
274 <li key={s.label}><a href="#result" onClick={back}><b>{s.label}</b><span>{s.kind}</span></a></li>
275 ))}
276 {!filtered.length && <li className="fd-isl-none">No match for “{q}”. Try “Solvik”.</li>}
277 </ul>
278 </div>
279 );
280 return (
281 <div id={menuId} className="fd-isl-menu">
282 <div className="fd-isl-mhead">
283 <span className="fd-isl-word">{brand}</span>
284 <button type="button" className="fd-isl-ic" aria-label="Close menu" aria-expanded={true} onClick={back} data-autofocus="">
285 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>
286 </button>
287 </div>
288 <ul className="fd-isl-mlist">
289 {links.map((l, i) => (
290 <li key={l.href} style={{ animationDelay: `${60 + i * 40}ms` }}>
291 <a href={l.href} aria-current={l.href === current ? "page" : undefined} onClick={back}>{l.label}</a>
292 </li>
293 ))}
294 </ul>
295 <button type="button" className="fd-isl-mnow" onClick={(e) => go("player", e.currentTarget)}>
296 <Cover t={track} size={40} />
297 <span><b>{track.title}</b><small>{track.artist}</small></span>
298 <Eq on={playing} />
299 </button>
300 </div>
301 );
302 };
303
304 return (
305 <nav className={`fd-isl${className ? " " + className : ""}`} aria-label="Primary" data-view={view}>
306 <style>{css}</style>
307 <div ref={island} className="fd-isl-island">
308 {prev && (
309 <div key={`out-${prev}`} className="fd-isl-layer fd-isl-out" aria-hidden="true" inert>
310 {render(prev)}
311 </div>
312 )}
313 <div key={`in-${view}`} ref={content} className="fd-isl-layer fd-isl-in" data-v={view}>
314 {render(view)}
315 </div>
316 </div>
317 {view !== "player" && (
318 <div className="fd-isl-dock" aria-hidden="true">
319 <span style={{ width: `${pct}%` }} />
320 </div>
321 )}
322 <PlayHidden playing={playing} />
323 </nav>
324 );
325}
326
327/** Keeps a polite announcement of play state for screen readers. */
328function PlayHidden({ playing }: { playing: boolean }) {
329 return <span className="fd-isl-sr" role="status" aria-live="polite">{playing ? "Playing" : "Paused"}</span>;
330}
331
332const sans = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif`;
333const mono = `ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace`;
334
335const css = `
336.fd-isl {
337 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
338 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
339 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
340 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
341 --fd-isl-bg: #050505;
342 --fd-isl-ink: #f2f2ee;
343 --fd-isl-dim: #8e8e88;
344 --fd-isl-accent: #e8ff3a;
345 position: fixed; left: 0; right: 0; top: 14px; z-index: 60; display: flex; flex-direction: column; align-items: center; margin: 0; padding: 0 12px; pointer-events: none;
346 font: 400 14px/1.3 ${sans};
347 color: var(--fd-isl-ink); background: transparent;
348}
349.fd-isl, .fd-isl *, .fd-isl *::before, .fd-isl *::after { box-sizing: border-box; }
350.fd-isl a:focus-visible, .fd-isl button:focus-visible, .fd-isl [role="slider"]:focus-visible { outline: 2px solid color-mix(in srgb, var(--fd-isl-accent) 75%, transparent); outline-offset: 2px; }
351.fd-isl-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
352.fd-isl-island { position: relative; overflow: hidden; margin: 0; padding: 0; max-width: 100%; border-radius: 30px; background: var(--fd-isl-bg); box-shadow: 0 0 0 1px rgba(255,255,255,0.07), 0 18px 50px rgba(0,0,0,0.45); pointer-events: auto; will-change: width, height; }
353.fd-isl-layer { position: absolute; left: 50%; top: 0; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 24px); margin: 0; padding: 0; }
354.fd-isl-in { animation: fd-isl-in var(--fd-dur-3) var(--fd-ease-out) both; animation-delay: 60ms; }
355.fd-isl-out { animation: fd-isl-out 160ms linear both; pointer-events: none; }
356@keyframes fd-isl-in { from { opacity: 0; transform: translateX(-50%) scale(0.96); filter: blur(4px); } }
357@keyframes fd-isl-out { to { opacity: 0; transform: translateX(-50%) scale(0.98); } }
358.fd-isl-ul { display: flex; margin: 0; padding: 0; list-style: none; }
359.fd-isl-ul li { margin: 0; padding: 0; }
360
361.fd-isl-links { display: flex; align-items: center; gap: 4px; height: 52px; margin: 0; padding: 0 8px 0 14px; }
362.fd-isl-logo { display: grid; place-items: center; width: 34px; height: 34px; margin: 0 6px 0 0; color: var(--fd-isl-ink); text-decoration: none; border-radius: 9999px; }
363.fd-isl-logo svg { display: block; }
364.fd-isl-link { display: block; margin: 0; padding: 8px 12px; border-radius: 9999px; font: 500 0.875rem/1 ${sans}; color: var(--fd-isl-dim); text-decoration: none; white-space: nowrap; transition: color var(--fd-dur-1) linear, background var(--fd-dur-1) linear; }
365.fd-isl-link:hover { color: var(--fd-isl-ink); background: #1a1a1a; }
366.fd-isl-link[aria-current="page"] { color: var(--fd-isl-ink); }
367.fd-isl-sep { display: block; width: 1px; height: 20px; margin: 0 6px; background: #262626; }
368.fd-isl-mini { display: flex; align-items: center; gap: 9px; max-width: 220px; height: 38px; margin: 0; padding: 0 12px 0 6px; border: 0; border-radius: 9999px; background: #151515; color: var(--fd-isl-ink); cursor: pointer; appearance: none; -webkit-appearance: none; transition: background var(--fd-dur-1) linear, transform var(--fd-dur-1) var(--fd-ease-out); }
369.fd-isl-mini:hover { background: #1f1f1f; }
370.fd-isl-mini:active { transform: scale(0.96); }
371.fd-isl-minit { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 0.8125rem/1 ${sans}; color: var(--fd-isl-ink); }
372.fd-isl-cover { position: relative; display: block; flex: none; overflow: hidden; border-radius: 6px; }
373.fd-isl-cover svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
374.fd-isl-cover b { position: absolute; left: 6px; top: 7px; font: 900 0.6875rem/0.9 ${sans}; letter-spacing: -0.02em; color: inherit; width: 3.2em; }
375.fd-isl-eq { display: inline-flex; align-items: flex-end; gap: 2px; flex: none; height: 12px; }
376.fd-isl-eq i { display: block; width: 2px; height: 4px; border-radius: 1px; background: var(--fd-isl-accent); }
377.fd-isl-eq i:nth-child(2) { height: 8px; } .fd-isl-eq i:nth-child(3) { height: 5px; } .fd-isl-eq i:nth-child(4) { height: 10px; }
378.fd-isl-eq[data-on] i { animation: fd-isl-eq 900ms var(--fd-ease-in-out) infinite alternate; }
379.fd-isl-eq[data-on] i:nth-child(2) { animation-delay: -300ms; } .fd-isl-eq[data-on] i:nth-child(3) { animation-delay: -600ms; } .fd-isl-eq[data-on] i:nth-child(4) { animation-delay: -150ms; }
380@keyframes fd-isl-eq { 0% { height: 2px; } 100% { height: 12px; } }
381.fd-isl-ic { display: grid; place-items: center; flex: none; width: 38px; height: 38px; margin: 0; padding: 0; border: 0; border-radius: 9999px; background: none; color: var(--fd-isl-ink); cursor: pointer; appearance: none; -webkit-appearance: none; transition: background var(--fd-dur-1) linear, transform var(--fd-dur-1) var(--fd-ease-out); }
382.fd-isl-ic svg { display: block; }
383.fd-isl-ic:hover { background: #1c1c1c; }
384.fd-isl-ic:active { transform: scale(0.92); }
385.fd-isl-menubtn { display: none; }
386
387.fd-isl-player { width: 400px; margin: 0; padding: 14px 14px 10px; }
388.fd-isl-ptop { display: flex; align-items: center; gap: 14px; }
389.fd-isl-ptop .fd-isl-cover { border-radius: 12px; }
390.fd-isl-pmeta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
391.fd-isl-pmeta b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 1rem/1.15 ${sans}; letter-spacing: -0.01em; color: var(--fd-isl-ink); }
392.fd-isl-pmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 0.8125rem/1.1 ${sans}; color: var(--fd-isl-dim); }
393.fd-isl-close { align-self: flex-start; width: 30px; height: 30px; background: #161616; }
394.fd-isl-prog { display: flex; align-items: center; gap: 10px; margin: 14px 2px 4px; font: 500 0.6875rem/1 ${mono}; color: var(--fd-isl-dim); font-variant-numeric: tabular-nums; }
395.fd-isl-prog > span { width: 34px; }
396.fd-isl-prog > span:last-child { text-align: right; }
397.fd-isl-bar { position: relative; flex: 1; height: 16px; cursor: pointer; touch-action: none; }
398.fd-isl-bar::before { content: ""; position: absolute; left: 0; right: 0; top: 6px; height: 4px; border-radius: 9999px; background: #262626; }
399.fd-isl-bar i { position: absolute; left: 0; top: 6px; height: 4px; border-radius: 9999px; background: var(--fd-isl-ink); transition: width 1s linear; }
400.fd-isl-bar b { position: absolute; top: 2px; width: 12px; height: 12px; margin-left: -6px; border-radius: 9999px; background: var(--fd-isl-ink); transform: scale(0); transition: transform var(--fd-dur-2) var(--fd-spring), left 1s linear; }
401.fd-isl-bar:hover b, .fd-isl-bar:focus-visible b { transform: scale(1); }
402.fd-isl-ctrls { display: flex; align-items: center; justify-content: space-between; margin: 0; padding: 4px 6px 0; }
403.fd-isl-play { display: grid; place-items: center; width: 30px; height: 30px; margin: 0; padding: 0; border: 0; border-radius: 9999px; background: var(--fd-isl-ink); color: #050505; cursor: pointer; appearance: none; -webkit-appearance: none; transition: transform var(--fd-dur-1) var(--fd-ease-out); }
404.fd-isl-play svg { display: block; }
405.fd-isl-play-big { width: 52px; height: 52px; background: var(--fd-isl-accent); }
406.fd-isl-play:active { transform: scale(0.92); }
407
408.fd-isl-search { width: 460px; margin: 0; padding: 6px; }
409.fd-isl-field { display: flex; align-items: center; gap: 10px; height: 44px; margin: 0; padding: 0 6px 0 12px; border-radius: 9999px; background: #151515; color: var(--fd-isl-dim); }
410.fd-isl-field svg { display: block; flex: none; }
411.fd-isl-field input { flex: 1; min-width: 0; height: 100%; margin: 0; padding: 0; border: 0; outline: 0; background: transparent; font: 400 1rem/1.2 ${sans}; color: var(--fd-isl-ink); appearance: none; -webkit-appearance: none; }
412.fd-isl-field input::placeholder { color: #6e6e69; }
413.fd-isl-cancel { margin: 0; padding: 8px 10px; border: 0; border-radius: 9999px; background: none; font: 500 0.8125rem/1 ${sans}; color: var(--fd-isl-accent); cursor: pointer; appearance: none; -webkit-appearance: none; }
414.fd-isl-res { margin: 0; padding: 6px 0 4px; list-style: none; }
415.fd-isl-res li { margin: 0; padding: 0; }
416.fd-isl-res a { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 10px 14px; border-radius: 16px; text-decoration: none; }
417.fd-isl-res a:hover, .fd-isl-res a:focus-visible { background: #141414; }
418.fd-isl-res b { font: 600 0.9375rem/1.2 ${sans}; color: var(--fd-isl-ink); }
419.fd-isl-res span { font: 400 0.75rem/1.2 ${mono}; color: var(--fd-isl-dim); }
420.fd-isl-none { padding: 14px !important; font: 400 0.875rem/1.3 ${sans}; color: var(--fd-isl-dim); }
421
422.fd-isl-menu { width: calc(100vw - 24px); max-width: 420px; margin: 0; padding: 10px 10px 12px; }
423.fd-isl-mhead { display: flex; align-items: center; justify-content: space-between; padding: 0 0 0 10px; }
424.fd-isl-word { font: 800 1.125rem/1 ${sans}; letter-spacing: -0.04em; color: var(--fd-isl-ink); }
425.fd-isl-mlist { margin: 6px 0 10px; padding: 0; list-style: none; }
426.fd-isl-mlist li { margin: 0; padding: 0; animation: fd-isl-rise var(--fd-dur-3) var(--fd-ease-out) both; }
427@keyframes fd-isl-rise { from { opacity: 0; transform: translateY(10px); } }
428.fd-isl-mlist a { display: block; margin: 0; padding: 12px 10px; border-radius: 14px; font: 700 1.75rem/1.05 ${sans}; letter-spacing: -0.04em; color: #5e5e59; text-decoration: none; }
429.fd-isl-mlist a[aria-current="page"] { color: var(--fd-isl-ink); }
430.fd-isl-mlist a[aria-current="page"]::after { content: ""; display: inline-block; width: 8px; height: 8px; margin: 0 0 0.3em 10px; border-radius: 9999px; background: var(--fd-isl-accent); vertical-align: middle; }
431.fd-isl-mnow { display: flex; align-items: center; gap: 12px; width: 100%; margin: 0; padding: 8px 14px 8px 8px; border: 0; border-radius: 18px; background: #151515; color: var(--fd-isl-ink); text-align: left; cursor: pointer; appearance: none; -webkit-appearance: none; }
432.fd-isl-mnow > span:nth-child(2) { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
433.fd-isl-mnow b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 600 0.875rem/1.1 ${sans}; }
434.fd-isl-mnow small { font: 400 0.75rem/1 ${sans}; color: var(--fd-isl-dim); }
435
436.fd-isl-dock { width: 64px; height: 3px; margin: 8px 0 0; border-radius: 9999px; background: rgba(255,255,255,0.12); overflow: hidden; }
437.fd-isl-dock span { display: block; height: 100%; background: var(--fd-isl-accent); transition: width 1s linear; }
438
439@media (max-width: 760px) {
440 .fd-isl-ul, .fd-isl-sep { display: none; }
441 .fd-isl-menubtn { display: grid; }
442 .fd-isl-links { padding: 0 6px 0 10px; gap: 2px; }
443 .fd-isl-mini { max-width: 170px; margin-right: 2px; }
444 .fd-isl-player, .fd-isl-search { width: calc(100vw - 24px); max-width: 420px; }
445}
446@media (prefers-reduced-motion: reduce) {
447 .fd-isl-in, .fd-isl-out, .fd-isl-mlist li, .fd-isl-eq[data-on] i { animation: none; }
448 .fd-isl-out { display: none; }
449 .fd-isl-bar i, .fd-isl-bar b, .fd-isl-dock span { transition: none; }
450}
451@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { .fd-isl-island { box-shadow: 0 0 0 1px #5a5a55; } }
452`;
453
454/* ── Demo ─────────────────────────────────────────────────────────────── */
455
456const SONGS = [
457 ["Ferry Bell", "3:41"], ["Harbour Lights (Night Version)", "4:14"], ["Salt on the Window", "3:02"], ["Hirtshals", "5:27"],
458 ["Low Tide", "4:48"], ["Nine Gulls", "2:16"], ["The Long Way Round the Fjord", "6:03"], ["Diesel & Rain", "3:39"],
459];
460
461export function Demo() {
462 return (
463 <div className="fd-isl-demo">
464 <style>{demoCss}</style>
465 <IslandNav />
466 <main>
467 <section className="fd-isl-demo-hero">
468 <span className="fd-isl-demo-art" aria-hidden="true">
469 <svg viewBox="0 0 40 40"><circle cx="33" cy="8" r="3.6" fill="#0a0a0a" /><path d="M0 30c6-4 10-4 14 0s8 4 14 0 8-4 12 0v10H0z" fill="#0a0a0a" /><path d="M0 24c6-4 10-4 14 0s8 4 14 0 8-4 12 0" fill="none" stroke="#0a0a0a" strokeWidth="1.2" /></svg>
470 <b>LOW<br />TIDE</b>
471 </span>
472 <div>
473 <p className="fd-isl-demo-k">Album · 2026 · Recorded on the night ferry, Bergen to Hirtshals</p>
474 <h1>Low Tide</h1>
475 <p className="fd-isl-demo-by">Maren Solvik · 11 songs, 46 min</p>
476 </div>
477 </section>
478 <ol className="fd-isl-demo-list">
479 {SONGS.map(([n, d], i) => (
480 <li key={n} data-on={i === 1 ? "" : undefined}><span>{String(i + 1).padStart(2, "0")}</span><b>{n}</b><span>{d}</span></li>
481 ))}
482 </ol>
483 </main>
484 </div>
485 );
486}
487
488const demoCss = `
489.fd-isl-demo { margin: 0; padding: 0; min-height: 140vh; background: #0f0f0e; color: #f2f2ee; font: 400 15px/1.5 ${sans}; }
490.fd-isl-demo *, .fd-isl-demo *::before, .fd-isl-demo *::after { box-sizing: border-box; }
491.fd-isl-demo main { display: block; max-width: 1080px; margin: 0 auto; padding: 120px 28px 120px; }
492.fd-isl-demo-hero { display: grid; grid-template-columns: 300px 1fr; gap: 40px; align-items: end; margin: 0 0 48px; }
493.fd-isl-demo-art { position: relative; display: block; aspect-ratio: 1; background: #e8ff3a; border-radius: 6px; overflow: hidden; }
494.fd-isl-demo-art svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
495.fd-isl-demo-art b { position: absolute; left: 20px; top: 18px; font: 900 3.5rem/0.85 ${sans}; letter-spacing: -0.05em; color: #0a0a0a; }
496.fd-isl-demo-k { margin: 0 0 12px; font: 500 0.75rem/1.4 ${mono}; color: #8e8e88; }
497.fd-isl-demo h1 { margin: 0; font: 800 clamp(4rem, 12vw, 10rem)/0.82 ${sans}; letter-spacing: -0.06em; color: #f2f2ee; }
498.fd-isl-demo-by { margin: 16px 0 0; font: 500 1rem/1 ${sans}; color: #c9c9c3; }
499.fd-isl-demo-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid #262624; }
500.fd-isl-demo-list li { display: grid; grid-template-columns: 48px 1fr auto; align-items: center; padding: 16px 4px; border-bottom: 1px solid #1e1e1c; font: 400 0.875rem/1 ${mono}; color: #8e8e88; }
501.fd-isl-demo-list b { font: 500 1.0625rem/1.2 ${sans}; color: #f2f2ee; }
502.fd-isl-demo-list li[data-on] b, .fd-isl-demo-list li[data-on] span:first-child { color: #e8ff3a; }
503@media (max-width: 760px) {
504 .fd-isl-demo main { padding: 96px 16px 80px; }
505 .fd-isl-demo-hero { grid-template-columns: 1fr; gap: 24px; }
506 .fd-isl-demo-art { width: 200px; }
507}
508`;

More Navbars. Same file contract.

Floating Capsule Nav, navbars preview: Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light…

Floating Capsule Nav

Sticky navbar that springs from a full-width bar into a floating capsule on scroll, with a live status light and version chip.

Command Nav, navbars preview: Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live…

Command Nav

Navbar with command palette: search opens a real ⌘K palette with recents, grouped fuzzy results and a live preview.

App Sidebar Nav, navbars preview: App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a…

App Sidebar Nav

App sidebar navigation for React: workspace switcher, nested groups, badges, a sliding active marker and a collapsible icon rail.

Questions about Island Nav

Run npx shadcn@latest add https://freedesignmd.com/r/island-nav.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Dynamic island navbar: a dark pill that spring-morphs between links, a now-playing mini player, search and a phone menu. It is a free, MIT-licensed React navbars component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. The morph is a rAF spring on width and height; outgoing content uses the inert attribute.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.