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
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 of3 * the page that morphs between states. Links by default, a now-playing mini4 * player when you tap the track, a search field with suggestions, and (on5 * phones) a tall menu. Width, height and corner radius are driven by an6 * 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, duration10 * 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 and15 * aria-controls; Escape returns to links and restores focus; clicking16 * outside also closes. Play/pause is a toggle button with aria-pressed; the17 * 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-deps171 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.
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.


