Skip to content

Masthead Nav

Newspaper masthead navbar: mono dateline, huge serif wordmark and a section rail that hands off to a slim bar on scroll.

  • Navbars
  • News & media
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (IntersectionObserver, CSS mask-image for the rail fades).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/masthead-nav.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/navbars/masthead-nav.tsx
1/**
2 * MastheadNav, a two-tier newspaper masthead. A mono dateline with edition
3 * and weather, a huge serif wordmark, and a section rail that scrolls
4 * sideways (with arrow buttons and edge fades when it overflows) ending in a
5 * Subscribe pill. When the masthead scrolls out of view a slim bar slides down
6 * with a small wordmark and a red reading-progress rule.
7 *
8 * Props: name, dateline, edition, weather, sections (label, href), current
9 * (href of the active section), subscribe ({ label, href }).
10 * CSS vars: --fd-mst-paper, --fd-mst-ink, --fd-mst-red.
11 *
12 * Behaviour: the big masthead scrolls with the page; the slim bar is
13 * `position: fixed` and appears via IntersectionObserver. Progress reads
14 * window scroll.
15 * Browser support: all evergreen browsers (mask-image for rail fades).
16 * Accessibility: the rail is a <nav> list with aria-current on the active
17 * section; arrow buttons are labelled and hidden when not needed. "Sections"
18 * opens a modal dialog with a focus trap; Escape closes it and returns focus.
19 * The slim bar is aria-hidden and untabbable while it is off-screen.
20 * Reduced motion: the slim bar appears without sliding.
21 */
22import { useEffect, useId, useRef, useState } from "react";
23
24export interface MstSection { label: string; href: string }
25export interface MastheadNavProps {
26 name?: string;
27 dateline?: string;
28 edition?: string;
29 weather?: string;
30 sections?: MstSection[];
31 current?: string;
32 subscribe?: { label: string; href: string; note?: string };
33 className?: string;
34}
35
36const SECTIONS: MstSection[] = [
37 { label: "Front Page", href: "#front" },
38 { label: "World", href: "#world" },
39 { label: "Europe", href: "#europe" },
40 { label: "Politics", href: "#politics" },
41 { label: "Business", href: "#business" },
42 { label: "Climate", href: "#climate" },
43 { label: "Science", href: "#science" },
44 { label: "Technology", href: "#tech" },
45 { label: "Opinion", href: "#opinion" },
46 { label: "Culture", href: "#culture" },
47 { label: "Books", href: "#books" },
48 { label: "Food", href: "#food" },
49 { label: "Travel", href: "#travel" },
50 { label: "Sport", href: "#sport" },
51 { label: "Obituaries", href: "#obits" },
52 { label: "Crossword", href: "#crossword" },
53];
54
55function Emblem({ size = 40 }: { size?: number }) {
56 return (
57 <svg className="fd-mst-emblem" width={size} height={size} viewBox="0 0 40 40" aria-hidden="true">
58 <circle cx="20" cy="20" r="17" fill="none" stroke="currentColor" strokeWidth="1.4" />
59 <ellipse cx="20" cy="20" rx="7.5" ry="17" fill="none" stroke="currentColor" strokeWidth="1.1" />
60 <path d="M3 20h34M6 11.5h28M6 28.5h28" fill="none" stroke="currentColor" strokeWidth="1" />
61 <path d="M20 1.5v37" stroke="var(--fd-mst-red)" strokeWidth="1.8" />
62 </svg>
63 );
64}
65
66export function MastheadNav({
67 name = "The Meridian Post",
68 dateline = "Tuesday, 3 November 2026",
69 edition = "Late City Edition · Vol. CXLII · No. 49,311",
70 weather = "Rotterdam 11°C, showers clearing by noon",
71 sections = SECTIONS,
72 current = "#front",
73 subscribe = { label: "Subscribe", href: "#subscribe", note: "€1 a week for 6 months" },
74 className,
75}: MastheadNavProps) {
76 const uid = useId();
77 const mast = useRef<HTMLDivElement>(null);
78 const scroller = useRef<HTMLUListElement>(null);
79 const prog = useRef<HTMLSpanElement>(null);
80 const openers = useRef<HTMLButtonElement | null>(null);
81 const dialog = useRef<HTMLDivElement>(null);
82 const [slim, setSlim] = useState(false);
83 const [edge, setEdge] = useState({ l: false, r: false });
84 const [open, setOpen] = useState(false);
85
86 // Slim bar appears once the big masthead has left the viewport.
87 useEffect(() => {
88 const el = mast.current;
89 if (!el) return;
90 const io = new IntersectionObserver(([e]) => setSlim(!e.isIntersecting && e.boundingClientRect.top < 0), { threshold: 0 });
91 io.observe(el);
92 return () => io.disconnect();
93 }, []);
94
95 // Reading progress (transform only).
96 useEffect(() => {
97 let raf = 0;
98 const on = () => {
99 if (raf) return;
100 raf = requestAnimationFrame(() => {
101 raf = 0;
102 const max = document.documentElement.scrollHeight - window.innerHeight;
103 const p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
104 if (prog.current) prog.current.style.transform = `scaleX(${p})`;
105 });
106 };
107 on();
108 window.addEventListener("scroll", on, { passive: true });
109 window.addEventListener("resize", on);
110 return () => {
111 cancelAnimationFrame(raf);
112 window.removeEventListener("scroll", on);
113 window.removeEventListener("resize", on);
114 };
115 }, []);
116
117 // Rail overflow edges.
118 useEffect(() => {
119 const s = scroller.current;
120 if (!s) return;
121 const check = () => setEdge({ l: s.scrollLeft > 4, r: s.scrollLeft + s.clientWidth < s.scrollWidth - 4 });
122 check();
123 s.addEventListener("scroll", check, { passive: true });
124 const ro = new ResizeObserver(check);
125 ro.observe(s);
126 return () => {
127 s.removeEventListener("scroll", check);
128 ro.disconnect();
129 };
130 }, []);
131
132 // Sections dialog.
133 useEffect(() => {
134 if (!open) return;
135 const d = dialog.current;
136 window.setTimeout(() => d?.querySelector<HTMLElement>("input")?.focus(), 30);
137 const onKey = (e: KeyboardEvent) => {
138 if (e.key === "Escape") {
139 setOpen(false);
140 openers.current?.focus();
141 }
142 if (e.key === "Tab" && d) {
143 const f = Array.from(d.querySelectorAll<HTMLElement>("a,button,input"));
144 const i = f.indexOf(document.activeElement as HTMLElement);
145 if (e.shiftKey && i <= 0) {
146 e.preventDefault();
147 f[f.length - 1].focus();
148 } else if (!e.shiftKey && i === f.length - 1) {
149 e.preventDefault();
150 f[0].focus();
151 }
152 }
153 };
154 document.addEventListener("keydown", onKey);
155 return () => document.removeEventListener("keydown", onKey);
156 }, [open]);
157
158 const nudge = (d: number) => {
159 const s = scroller.current;
160 if (!s) return;
161 const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
162 s.scrollBy({ left: d * s.clientWidth * 0.6, behavior: reduce ? "auto" : "smooth" });
163 };
164 const openMenu = (btn: HTMLButtonElement) => {
165 openers.current = btn;
166 setOpen(true);
167 };
168 const close = () => {
169 setOpen(false);
170 openers.current?.focus();
171 };
172
173 const [the, ...rest] = name.split(" ");
174 const hasThe = /^the$/i.test(the);
175 const dlgId = `${uid}-sections`;
176
177 const burger = (compact?: boolean) => (
178 <button type="button" className="fd-mst-sec" aria-expanded={open} aria-controls={dlgId} tabIndex={compact && !slim ? -1 : undefined} onClick={(e) => openMenu(e.currentTarget)}>
179 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2 4.5h14M2 9h14M2 13.5h14" stroke="currentColor" strokeWidth="1.4" /></svg>
180 <span>Sections</span>
181 </button>
182 );
183
184 return (
185 <div className={`fd-mst${className ? " " + className : ""}`}>
186 <style>{css}</style>
187 <header className="fd-mst-head">
188 <div ref={mast} className="fd-mst-mast">
189 <div className="fd-mst-top">
190 <div className="fd-mst-tl">
191 {burger()}
192 <a className="fd-mst-search" href="#search" aria-label="Search">
193 <svg width="17" height="17" viewBox="0 0 18 18" aria-hidden="true"><circle cx="7.5" cy="7.5" r="5.5" fill="none" stroke="currentColor" strokeWidth="1.4" /><path d="M11.5 11.5l5 5" stroke="currentColor" strokeWidth="1.4" /></svg>
194 </a>
195 </div>
196 <p className="fd-mst-date">{dateline}</p>
197 <div className="fd-mst-tr">
198 <a className="fd-mst-login" href="#login">Log in</a>
199 <a className="fd-mst-sub fd-mst-sub-top" href={subscribe.href}>{subscribe.label}</a>
200 </div>
201 </div>
202 <a className="fd-mst-brand" href="#front" aria-label={`${name}, front page`}>
203 {hasThe && <span className="fd-mst-the">{the}</span>}
204 <Emblem size={56} />
205 <span className="fd-mst-name">{hasThe ? rest.join(" ") : name}</span>
206 </a>
207 <div className="fd-mst-meta">
208 <span>{edition}</span>
209 <span className="fd-mst-wx">
210 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 10.5h6.2a2.3 2.3 0 0 0 .3-4.6 3.2 3.2 0 0 0-6.2.6A2 2 0 0 0 4 10.5z" fill="none" stroke="currentColor" strokeWidth="1.1" /></svg>
211 {weather}
212 </span>
213 <span>Price €3.80</span>
214 </div>
215 </div>
216 <nav className="fd-mst-railwrap" aria-label="Sections">
217 <div className="fd-mst-rail" data-l={edge.l ? "" : undefined} data-r={edge.r ? "" : undefined}>
218 <button type="button" className="fd-mst-arrow fd-mst-al" aria-label="Scroll sections left" hidden={!edge.l} onClick={() => nudge(-1)}>
219 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9 2.5L4.5 7 9 11.5" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>
220 </button>
221 <ul ref={scroller} className="fd-mst-list">
222 {sections.map((s) => (
223 <li key={s.href}>
224 <a href={s.href} className="fd-mst-link" aria-current={s.href === current ? "page" : undefined}>{s.label}</a>
225 </li>
226 ))}
227 </ul>
228 <button type="button" className="fd-mst-arrow fd-mst-ar" aria-label="Scroll sections right" hidden={!edge.r} onClick={() => nudge(1)}>
229 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M5 2.5L9.5 7 5 11.5" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>
230 </button>
231 </div>
232 <a className="fd-mst-sub fd-mst-sub-rail" href={subscribe.href}>
233 {subscribe.label}
234 {subscribe.note && <small>{subscribe.note}</small>}
235 </a>
236 </nav>
237 </header>
238
239 <div className="fd-mst-slim" data-on={slim ? "" : undefined} aria-hidden={!slim}>
240 <div className="fd-mst-slimrow">
241 {burger(true)}
242 <a className="fd-mst-slimbrand" href="#front" tabIndex={slim ? undefined : -1}>
243 <Emblem size={22} />
244 <span>{name}</span>
245 </a>
246 <a className="fd-mst-sub" href={subscribe.href} tabIndex={slim ? undefined : -1}>{subscribe.label}</a>
247 </div>
248 <span className="fd-mst-prog" aria-hidden="true"><span ref={prog} /></span>
249 </div>
250
251 <div className="fd-mst-dwrap" hidden={!open}>
252 <div className="fd-mst-dscrim" aria-hidden="true" onClick={close} />
253 <div ref={dialog} id={dlgId} className="fd-mst-drawer" role="dialog" aria-modal="true" aria-label="All sections">
254 <div className="fd-mst-dhead">
255 <span className="fd-mst-dtitle">Sections</span>
256 <button type="button" className="fd-mst-x" aria-label="Close sections" onClick={close}>
257 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M3 3l12 12M15 3L3 15" stroke="currentColor" strokeWidth="1.4" /></svg>
258 </button>
259 </div>
260 <form className="fd-mst-form" role="search" onSubmit={(e) => e.preventDefault()}>
261 <label htmlFor={`${uid}-q`} className="fd-mst-sr">Search the archive</label>
262 <input id={`${uid}-q`} type="search" placeholder="Search 178 years of archive" />
263 </form>
264 <ul className="fd-mst-dlist">
265 {sections.map((s, i) => (
266 <li key={s.href}>
267 <a href={s.href} onClick={close} aria-current={s.href === current ? "page" : undefined}>
268 <span className="fd-mst-dn">{String(i + 1).padStart(2, "0")}</span>
269 {s.label}
270 </a>
271 </li>
272 ))}
273 </ul>
274 <div className="fd-mst-dfoot">
275 <a className="fd-mst-sub" href={subscribe.href}>{subscribe.label}</a>
276 <a className="fd-mst-login" href="#login">Log in</a>
277 </div>
278 <p className="fd-mst-dmeta">{dateline} · {weather}</p>
279 </div>
280 </div>
281 </div>
282 );
283}
284
285const serif = `"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif`;
286const sans = `ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif`;
287const mono = `ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace`;
288
289const css = `
290.fd-mst {
291 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
292 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
293 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
294 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
295 --fd-mst-paper: #f1eee6;
296 --fd-mst-ink: #151412;
297 --fd-mst-soft: #5d5a53;
298 --fd-mst-rule: #c9c4b8;
299 --fd-mst-red: #c4161c;
300 display: block; margin: 0; padding: 0;
301 font: 400 15px/1.4 ${sans};
302 color: var(--fd-mst-ink); background: var(--fd-mst-paper);
303}
304.fd-mst, .fd-mst *, .fd-mst *::before, .fd-mst *::after { box-sizing: border-box; }
305.fd-mst a:focus-visible, .fd-mst button:focus-visible, .fd-mst input:focus-visible { outline: 2px solid color-mix(in srgb, var(--fd-mst-red) 70%, transparent); outline-offset: 2px; }
306.fd-mst-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
307.fd-mst-head { display: block; margin: 0; padding: 0 32px; background: var(--fd-mst-paper); }
308.fd-mst-mast { margin: 0; padding: 0; }
309.fd-mst-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; height: 56px; margin: 0; padding: 0; border-bottom: 1px solid var(--fd-mst-rule); }
310.fd-mst-tl, .fd-mst-tr { display: flex; align-items: center; gap: 10px; margin: 0; padding: 0; }
311.fd-mst-tr { justify-content: flex-end; gap: 18px; }
312.fd-mst-sec { display: inline-flex; align-items: center; gap: 8px; margin: 0 0 0 -8px; padding: 8px; border: 0; border-radius: 2px; background: none; font: 600 0.8125rem/1 ${sans}; letter-spacing: 0.02em; color: var(--fd-mst-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }
313.fd-mst-sec svg, .fd-mst-search svg { display: block; }
314.fd-mst-sec:hover span { text-decoration: underline; text-underline-offset: 3px; }
315.fd-mst-search { display: grid; place-items: center; width: 34px; height: 34px; margin: 0; padding: 0; color: var(--fd-mst-ink); text-decoration: none; border-left: 1px solid var(--fd-mst-rule); }
316.fd-mst-date { margin: 0; padding: 0; font: 500 0.75rem/1 ${mono}; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fd-mst-ink); white-space: nowrap; }
317.fd-mst-login { font: 600 0.8125rem/1 ${sans}; color: var(--fd-mst-ink); text-decoration: none; }
318.fd-mst-login:hover { text-decoration: underline; text-underline-offset: 3px; }
319.fd-mst-sub { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; margin: 0; padding: 9px 18px; border-radius: 9999px; font: 700 0.8125rem/1 ${sans}; letter-spacing: 0.02em; color: #fff; background: var(--fd-mst-red); text-decoration: none; white-space: nowrap; transition: transform var(--fd-dur-1) var(--fd-ease-out), background var(--fd-dur-1) linear; }
320.fd-mst-sub:hover { background: #a31015; }
321.fd-mst-sub:active { transform: scale(0.96); }
322.fd-mst-sub small { display: block; font: 500 0.625rem/1 ${mono}; letter-spacing: 0.02em; color: rgba(255,255,255,0.86); }
323
324.fd-mst-brand { display: flex; align-items: center; justify-content: center; gap: 0.18em; margin: 0; padding: 26px 0 14px; color: var(--fd-mst-ink); text-decoration: none; font: 700 clamp(2.5rem, 8.4vw, 7.25rem)/0.9 ${serif}; letter-spacing: -0.035em; }
325.fd-mst-brand .fd-mst-emblem { display: block; width: 0.58em; height: 0.58em; flex: none; color: var(--fd-mst-ink); margin: 0 0.04em; }
326.fd-mst-the { font: italic 400 0.42em/1 ${serif}; letter-spacing: 0; align-self: center; margin: 0 0.06em 0 0; }
327.fd-mst-name { display: block; white-space: nowrap; }
328.fd-mst-meta { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin: 0; padding: 9px 0; border-top: 3px double var(--fd-mst-ink); font: 500 0.6875rem/1.3 ${mono}; letter-spacing: 0.04em; text-transform: uppercase; color: var(--fd-mst-soft); }
329.fd-mst-wx { display: inline-flex; align-items: center; gap: 7px; color: var(--fd-mst-ink); }
330.fd-mst-wx svg { display: block; }
331
332.fd-mst-railwrap { display: flex; align-items: center; gap: 16px; margin: 0; padding: 0; border-top: 1px solid var(--fd-mst-ink); border-bottom: 1px solid var(--fd-mst-ink); }
333.fd-mst-rail { position: relative; flex: 1; min-width: 0; margin: 0; padding: 0; }
334.fd-mst-list { display: flex; gap: 0; margin: 0; padding: 0; list-style: none; overflow-x: auto; scrollbar-width: none; scroll-snap-type: x proximity; }
335.fd-mst-list::-webkit-scrollbar { display: none; }
336.fd-mst-rail[data-r] .fd-mst-list { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 72px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 72px), transparent); }
337.fd-mst-rail[data-l] .fd-mst-list { -webkit-mask-image: linear-gradient(90deg, transparent, #000 72px); mask-image: linear-gradient(90deg, transparent, #000 72px); }
338.fd-mst-rail[data-l][data-r] .fd-mst-list { -webkit-mask-image: linear-gradient(90deg, transparent, #000 72px, #000 calc(100% - 72px), transparent); mask-image: linear-gradient(90deg, transparent, #000 72px, #000 calc(100% - 72px), transparent); }
339.fd-mst-list li { flex: none; margin: 0; padding: 0; scroll-snap-align: start; }
340.fd-mst-link { position: relative; display: block; margin: 0; padding: 14px 14px 13px; font: 500 0.875rem/1 ${serif}; letter-spacing: 0; color: var(--fd-mst-ink); text-decoration: none; white-space: nowrap; }
341.fd-mst-list li:first-child .fd-mst-link { padding-left: 0; }
342.fd-mst-link::after { content: ""; position: absolute; left: 14px; right: 14px; bottom: -1px; height: 3px; background: var(--fd-mst-red); transform: scaleX(0); transform-origin: left; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
343.fd-mst-list li:first-child .fd-mst-link::after { left: 0; }
344.fd-mst-link:hover::after { transform: scaleX(1); background: var(--fd-mst-ink); }
345.fd-mst-link[aria-current="page"] { font-weight: 700; }
346.fd-mst-link[aria-current="page"]::after { transform: scaleX(1); }
347.fd-mst-arrow { position: absolute; top: 50%; z-index: 2; display: grid; place-items: center; width: 28px; height: 28px; margin: -14px 0 0; padding: 0; border: 1px solid var(--fd-mst-ink); border-radius: 9999px; background: var(--fd-mst-paper); color: var(--fd-mst-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }
348.fd-mst-arrow[hidden] { display: none; }
349.fd-mst-arrow svg { display: block; }
350.fd-mst-al { left: 0; }
351.fd-mst-ar { right: 0; }
352.fd-mst-sub-rail { margin: 6px 0; padding: 7px 16px; }
353
354.fd-mst-slim { position: fixed; left: 0; right: 0; top: 0; z-index: 50; margin: 0; padding: 0; background: var(--fd-mst-paper); border-bottom: 1px solid var(--fd-mst-ink); transform: translateY(-110%); transition: transform var(--fd-dur-3) var(--fd-ease-out); visibility: hidden; }
355.fd-mst-slim[data-on] { transform: none; visibility: visible; }
356.fd-mst-slim:not([data-on]) { transition: transform var(--fd-dur-2) var(--fd-ease-in-out), visibility 0s linear var(--fd-dur-2); }
357.fd-mst-slimrow { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 52px; margin: 0; padding: 0 32px; }
358.fd-mst-slimrow .fd-mst-sub { justify-self: end; padding: 8px 16px; }
359.fd-mst-slimbrand { display: inline-flex; align-items: center; gap: 8px; color: var(--fd-mst-ink); text-decoration: none; font: 700 1.375rem/1 ${serif}; letter-spacing: -0.02em; }
360.fd-mst-slimbrand .fd-mst-emblem { display: block; }
361.fd-mst-prog { position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; margin: 0; padding: 0; }
362.fd-mst-prog span { display: block; height: 100%; background: var(--fd-mst-red); transform: scaleX(0); transform-origin: left; }
363
364.fd-mst-dwrap { position: fixed; inset: 0; z-index: 60; }
365.fd-mst-dwrap[hidden] { display: none; }
366.fd-mst-dscrim { position: absolute; inset: 0; background: rgba(21,20,18,0.45); animation: fd-mst-fade var(--fd-dur-3) var(--fd-ease-out); }
367@keyframes fd-mst-fade { from { opacity: 0; } }
368.fd-mst-drawer { position: absolute; left: 0; top: 0; bottom: 0; width: min(440px, 100%); overflow-y: auto; margin: 0; padding: 0 28px 32px; background: var(--fd-mst-paper); border-right: 1px solid var(--fd-mst-ink); animation: fd-mst-in var(--fd-dur-3) var(--fd-ease-out); }
369@keyframes fd-mst-in { from { transform: translateX(-100%); } }
370.fd-mst-dhead { display: flex; align-items: center; justify-content: space-between; height: 64px; margin: 0; padding: 0; border-bottom: 3px double var(--fd-mst-ink); }
371.fd-mst-dtitle { font: 700 1.5rem/1 ${serif}; letter-spacing: -0.02em; color: var(--fd-mst-ink); }
372.fd-mst-x { display: grid; place-items: center; width: 40px; height: 40px; margin: 0 -10px 0 0; padding: 0; border: 0; background: none; color: var(--fd-mst-ink); cursor: pointer; appearance: none; -webkit-appearance: none; }
373.fd-mst-x svg { display: block; }
374.fd-mst-form { margin: 18px 0 8px; padding: 0; }
375.fd-mst-form input { display: block; width: 100%; margin: 0; padding: 12px 14px; border: 1px solid var(--fd-mst-ink); border-radius: 0; background: #fbf9f4; font: 400 1rem/1.2 ${serif}; color: var(--fd-mst-ink); appearance: none; -webkit-appearance: none; }
376.fd-mst-dlist { display: grid; grid-template-columns: 1fr 1fr; column-gap: 24px; margin: 8px 0 0; padding: 0; list-style: none; }
377.fd-mst-dlist li { margin: 0; padding: 0; border-bottom: 1px solid var(--fd-mst-rule); }
378.fd-mst-dlist a { display: flex; align-items: baseline; gap: 10px; margin: 0; padding: 12px 0; font: 400 1.1875rem/1.2 ${serif}; color: var(--fd-mst-ink); text-decoration: none; }
379.fd-mst-dlist a:hover { color: var(--fd-mst-red); }
380.fd-mst-dlist a[aria-current="page"] { font-weight: 700; }
381.fd-mst-dn { font: 500 0.6875rem/1 ${mono}; color: var(--fd-mst-soft); }
382.fd-mst-dfoot { display: flex; align-items: center; gap: 20px; margin: 24px 0 0; padding: 0; }
383.fd-mst-dmeta { margin: 20px 0 0; font: 500 0.6875rem/1.5 ${mono}; letter-spacing: 0.04em; text-transform: uppercase; color: var(--fd-mst-soft); }
384
385@media (max-width: 900px) {
386 .fd-mst-meta span:last-child { display: none; }
387}
388@media (max-width: 680px) {
389 .fd-mst-head { padding: 0 16px; }
390 .fd-mst-top { grid-template-columns: auto 1fr auto; height: 48px; }
391 .fd-mst-date { text-align: center; font-size: 0.625rem; letter-spacing: 0.04em; }
392 .fd-mst-search, .fd-mst-top .fd-mst-login, .fd-mst-sec span { display: none; }
393 .fd-mst-sub-top { padding: 7px 12px; font-size: 0.75rem; }
394 .fd-mst-brand { padding: 18px 0 10px; font-size: clamp(2rem, 10.4vw, 3rem); }
395 .fd-mst-meta { justify-content: center; }
396 .fd-mst-meta span:first-child { display: none; }
397 .fd-mst-sub-rail { display: none; }
398 .fd-mst-arrow { display: none; }
399 .fd-mst-slimrow { padding: 0 16px; grid-template-columns: auto 1fr auto; gap: 8px; }
400 .fd-mst-slimrow .fd-mst-sec span { display: none; }
401 .fd-mst-slimbrand { justify-self: center; font-size: 1.0625rem; }
402 .fd-mst-slimrow .fd-mst-sub { padding: 7px 12px; font-size: 0.75rem; }
403 .fd-mst-drawer { padding: 0 20px 28px; }
404}
405@media (prefers-reduced-motion: reduce) {
406 .fd-mst-slim, .fd-mst-slim:not([data-on]), .fd-mst-link::after { transition: none; }
407 .fd-mst-drawer, .fd-mst-dscrim { animation: none; }
408}
409`;
410
411/* ── Demo ─────────────────────────────────────────────────────────────── */
412
413const MORE = [
414 { k: "Energy", h: "The North Sea's oldest oil platform will become a wind farm's workshop", d: "Brent Delta's legs stay. Everything above the waterline goes, by barge, to Den Helder." },
415 { k: "Cities", h: "Paris counted its trees. There are 486,511, and 300 of them have names", d: "The city's arborists spent four summers with clipboards and a laser. One plane tree is 241." },
416 { k: "Science", h: "Why the Wadden Sea's mud is the best carbon store you have never heard of", d: "A square metre of tidal flat locks away more carbon than a square metre of forest, for longer." },
417 { k: "Opinion", h: "Dikes are a promise. We should stop making ones we cannot keep", d: "A former water minister on why the next fifty years need fewer walls and more room." },
418];
419
420export function Demo() {
421 return (
422 <div className="fd-mst-demo">
423 <style>{demoCss}</style>
424 <MastheadNav />
425 <main className="fd-mst-demo-main">
426 <article className="fd-mst-demo-lead">
427 <p className="fd-mst-demo-kick">Climate · Exclusive</p>
428 <h1>Rotterdam will let the river in: the city's new plan floods 40 parks on purpose</h1>
429 <p className="fd-mst-demo-deck">Engineers say the Maas will rise faster than dikes can be raised. The answer, approved at 1:40 a.m., is to give the water somewhere to go.</p>
430 <p className="fd-mst-demo-by">By Annelies de Vries and Tomás Ferreira · Rotterdam · 6 min read</p>
431 <div className="fd-mst-demo-cols" lang="en">
432 <p><span className="fd-mst-demo-drop">T</span>he vote came after eleven hours of debate and one power cut. By a margin of 23 to 22, the council approved a plan to redesign forty parks, squares and sports fields so that each can hold water during a storm surge, then drain it slowly back to the river within two days.</p>
433 <p>The first site, a football pitch in Feijenoord, already sits 1.8 metres below its old level. On a dry afternoon it is a pitch. During last February's storm it held 9,000 cubic metres of water, enough to keep two streets of terraced houses dry.</p>
434 <p>“We stopped asking how to keep the water out,” said Ilse Vermeer, the city's chief water engineer, who has spent nineteen years on the problem. “We started asking where it would like to be.”</p>
435 <p>Critics on the council argued the €610 million budget would be better spent on raising the Maasboulevard dike by another metre. Ms. Vermeer's answer was a chart: every metre of dike has cost more than the last.</p>
436 <p>Construction on the next nine sites begins in March. Residents near each one will receive a letter, a map, and, at the council's insistence, an apology for the noise.</p>
437 </div>
438 </article>
439 <aside className="fd-mst-demo-side">
440 <h2>Most read</h2>
441 <ol>
442 <li><b>The last ferry to Texel now runs on batteries, and it is quieter than the gulls</b></li>
443 <li><b>A Utrecht bakery has made the same rye loaf every morning since 1887</b></li>
444 <li><b>Opinion: Europe's night trains need a timetable, not another summit</b></li>
445 <li><b>The Dutch chess prodigy who learned the game from a library book</b></li>
446 <li><b>Crossword No. 18,204: Thursday's cryptic, set by Hecate</b></li>
447 </ol>
448 <div className="fd-mst-demo-brief">
449 <h2>In brief</h2>
450 <p><b>Markets.</b> The AEX closed up 0.6% as shipping stocks recovered.</p>
451 <p><b>Weather.</b> Showers clearing by noon, a dry and cold evening. High 11°C.</p>
452 <p><b>Corrections.</b> Monday's map of the Maas misplaced Dordrecht by eleven kilometres.</p>
453 </div>
454 </aside>
455 <section className="fd-mst-demo-more" aria-label="More from Climate">
456 <h2>More from Climate</h2>
457 <div>
458 {MORE.map((m) => (
459 <article key={m.h}>
460 <p className="fd-mst-demo-kick">{m.k}</p>
461 <h3>{m.h}</h3>
462 <p>{m.d}</p>
463 </article>
464 ))}
465 </div>
466 </section>
467 </main>
468 </div>
469 );
470}
471
472const demoCss = `
473.fd-mst-demo { margin: 0; padding: 0; min-height: 100vh; background: #f1eee6; color: #151412; font: 400 15px/1.5 ${serif}; }
474.fd-mst-demo *, .fd-mst-demo *::before, .fd-mst-demo *::after { box-sizing: border-box; }
475.fd-mst-demo-main { display: grid; grid-template-columns: minmax(0, 2.4fr) minmax(0, 1fr); gap: 40px; margin: 0; padding: 32px 32px 160px; }
476.fd-mst-demo-lead { padding: 0 40px 0 0; border-right: 1px solid #c9c4b8; }
477.fd-mst-demo-kick { margin: 0 0 12px; font: 600 0.75rem/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #c4161c; }
478.fd-mst-demo h1 { margin: 0; font: 700 clamp(2rem, 4.2vw, 3.5rem)/1.02 ${serif}; letter-spacing: -0.025em; color: #151412; }
479.fd-mst-demo-deck { margin: 16px 0 14px; font: italic 400 1.25rem/1.4 ${serif}; color: #3b3934; }
480.fd-mst-demo-by { margin: 0 0 24px; padding: 0 0 14px; border-bottom: 1px solid #c9c4b8; font: 600 0.75rem/1.3 ${sans}; letter-spacing: 0.02em; color: #5d5a53; }
481.fd-mst-demo-cols { columns: 3 220px; column-gap: 28px; column-rule: 1px solid #d8d3c7; }
482.fd-mst-demo-cols p { margin: 0 0 14px; font: 400 1rem/1.55 ${serif}; color: #24221f; text-align: justify; hyphens: auto; }
483.fd-mst-demo-drop { float: left; margin: 4px 8px 0 0; font: 700 3.6rem/0.8 ${serif}; }
484.fd-mst-demo-side h2 { margin: 0 0 12px; padding: 0 0 8px; border-bottom: 3px double #151412; font: 700 1.125rem/1 ${serif}; }
485.fd-mst-demo-side ol { margin: 0 0 32px; padding: 0; list-style: none; counter-reset: mr; }
486.fd-mst-demo-side li { counter-increment: mr; display: grid; grid-template-columns: 32px 1fr; padding: 12px 0; border-bottom: 1px solid #c9c4b8; }
487.fd-mst-demo-side li::before { content: counter(mr); font: 700 1.5rem/1 ${serif}; color: #c4161c; }
488.fd-mst-demo-side li b { font: 400 1.0625rem/1.3 ${serif}; }
489.fd-mst-demo-brief p { margin: 0 0 12px; font: 400 0.9375rem/1.45 ${serif}; color: #24221f; }
490.fd-mst-demo-more { grid-column: 1 / -1; margin: 24px 0 0; padding: 0; }
491.fd-mst-demo-more > h2 { margin: 0 0 18px; padding: 0 0 8px; border-bottom: 3px double #151412; font: 700 1.375rem/1 ${serif}; }
492.fd-mst-demo-more > div { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; }
493.fd-mst-demo-more article { padding: 4px 20px 24px; border-left: 1px solid #c9c4b8; }
494.fd-mst-demo-more article:first-child { padding-left: 0; border-left: 0; }
495.fd-mst-demo-more h3 { margin: 0 0 10px; font: 700 1.3125rem/1.15 ${serif}; letter-spacing: -0.01em; }
496.fd-mst-demo-more article > p:last-child { margin: 0; font: 400 0.9375rem/1.45 ${serif}; color: #3b3934; }
497@media (max-width: 860px) {
498 .fd-mst-demo-more > div { grid-template-columns: 1fr; }
499 .fd-mst-demo-more article { padding: 16px 0; border-left: 0; border-top: 1px solid #c9c4b8; }
500 .fd-mst-demo-main { grid-template-columns: 1fr; padding: 24px 16px 120px; }
501 .fd-mst-demo-lead { padding: 0; border: 0; }
502}
503`;

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 Masthead Nav

Run npx shadcn@latest add https://freedesignmd.com/r/masthead-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.

Newspaper masthead navbar: mono dateline, huge serif wordmark and a section rail that hands off to a slim bar on scroll. 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 (IntersectionObserver, CSS mask-image for the rail fades).

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.