1const { useState, useEffect } = React;
2
3// --- Global hooks ---
4
5function useScrollReveal() {
6 useEffect(() => {
7 const els = document.querySelectorAll(".reveal");
8 if (!els.length) return;
9 if (!("IntersectionObserver" in window)) {
10 els.forEach((el) => el.classList.add("visible"));
11 return;
12 }
13 const obs = new IntersectionObserver(
14 (entries) => {
15 entries.forEach((e) => {
16 if (e.isIntersecting) {
17 e.target.classList.add("visible");
18 obs.unobserve(e.target);
19 }
20 });
21 },
22 { threshold: 0.08, rootMargin: "0px 0px -40px 0px" },
23 );
24 els.forEach((el) => obs.observe(el));
25 return () => obs.disconnect();
26 }, []);
27}
28
29function useMouseGlow() {
30 useEffect(() => {
31 const move = (e) => {
32 document.documentElement.style.setProperty("--mx", e.clientX + "px");
33 document.documentElement.style.setProperty("--my", e.clientY + "px");
34 };
35 window.addEventListener("mousemove", move, { passive: true });
36 return () => window.removeEventListener("mousemove", move);
37 }, []);
38}
39
40// --- Components ---
41
42function FloatpaneMark({ size = 20 }) {
43 return (
44 <img
45 src="../assets/floatpane.png"
46 alt="Floatpane logo"
47 className="wm-logo"
48 height={size}
49 width={size}
50 />
51 );
52}
53
54function MatchaWordmark() {
55 const [version, setVersion] = useState("v0.8.2");
56 useEffect(() => {
57 fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
58 .then((r) => (r.ok ? r.json() : null))
59 .then((d) => {
60 if (d && d.tag_name)
61 setVersion(
62 d.tag_name.startsWith("v") ? d.tag_name : "v" + d.tag_name,
63 );
64 })
65 .catch(() => {});
66 }, []);
67 return (
68 <div className="wordmark">
69 <img
70 src="../assets/logo-transparent.png"
71 alt="Matcha logo"
72 className="wm-logo"
73 height={24}
74 width={24}
75 />
76 <span className="wm-name">matcha</span>
77 <span className="wm-dim">— {version}</span>
78 </div>
79 );
80}
81
82function TopNav() {
83 const [stars, setStars] = useState(null);
84 useEffect(() => {
85 fetch("https://api.github.com/repos/floatpane/matcha")
86 .then((r) => (r.ok ? r.json() : null))
87 .then((d) => {
88 if (d && typeof d.stargazers_count === "number") {
89 const n = d.stargazers_count;
90 setStars(n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
91 }
92 })
93 .catch(() => {});
94 }, []);
95 return (
96 <header className="nav">
97 <a href="#top" className="nav-brand">
98 <MatchaWordmark />
99 </a>
100 <nav className="nav-links">
101 <a href="#features">Features</a>
102 <a href="#install">Install</a>
103 <a href="https://docs.matcha.email">Docs ↗</a>
104 <a href="https://github.com/floatpane/matcha" className="nav-github">
105 <span>GitHub</span>
106 {stars && <span className="nav-star">★ {stars}</span>}
107 </a>
108 </nav>
109 <div className="nav-right">
110 <a href="#install" className="btn btn-ghost">
111 install
112 </a>
113 </div>
114 </header>
115 );
116}
117
118function QuickStart() {
119 const CMD1 = "brew install floatpane/matcha/matcha";
120 const CMD2 = "matcha";
121 const [l1, setL1] = useState("");
122 const [l2, setL2] = useState("");
123 const [phase, setPhase] = useState("pre"); // pre → t1 → pause → t2 → done
124
125 useEffect(() => {
126 let t;
127 if (phase === "pre") {
128 t = setTimeout(() => setPhase("t1"), 1400);
129 } else if (phase === "t1") {
130 if (l1.length < CMD1.length) {
131 t = setTimeout(() => setL1(CMD1.slice(0, l1.length + 1)), 36);
132 } else {
133 t = setTimeout(() => setPhase("pause"), 420);
134 }
135 } else if (phase === "pause") {
136 t = setTimeout(() => setPhase("t2"), 320);
137 } else if (phase === "t2") {
138 if (l2.length < CMD2.length) {
139 t = setTimeout(() => setL2(CMD2.slice(0, l2.length + 1)), 90);
140 } else {
141 setPhase("done");
142 }
143 }
144 return () => clearTimeout(t);
145 }, [phase, l1, l2]);
146
147 const caret1 = phase === "pre" || phase === "t1" || phase === "pause";
148 const showL2 = phase === "t2" || phase === "done";
149
150 return (
151 <div className="quickstart">
152 <div className="qs-bar">
153 <span className="qs-dot qs-r" />
154 <span className="qs-dot qs-y" />
155 <span className="qs-dot qs-g" />
156 <span className="qs-bar-label">terminal</span>
157 </div>
158 <pre className="qs-code">
159 <span className="qs-prompt">$ </span>
160 {l1}
161 {caret1 && <span className="qs-caret" />}
162 {showL2 && (
163 <>
164 {"\n"}
165 <span className="qs-prompt">$ </span>
166 {l2}
167 <span className="qs-caret" />
168 </>
169 )}
170 </pre>
171 </div>
172 );
173}
174
175function Hero() {
176 return (
177 <section className="hero" id="top">
178 <div className="hero-copy">
179 <div className="hero-eyebrow">
180 <span className="dot-live" />
181 <span>by floatpane · local-first · secure · no telemetry</span>
182 </div>
183 <h1 className="hero-h1">
184 Email for people who
185 <br />
186 live in the <em>terminal.</em>
187 </h1>
188 <p className="hero-sub">
189 Matcha is a keyboard-native email client built for the shell.
190 Multi-account IMAP, PGP encryption, markdown composing, and a CLI
191 that pipes. One static binary. No cloud. No trackers.
192 </p>
193 <div className="hero-cta">
194 <a href="#install" className="btn btn-primary btn-lg">
195 Install now
196 </a>
197 <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
198 Read the docs <span className="btn-k">→</span>
199 </a>
200 </div>
201 <QuickStart />
202 <div className="hero-meta">
203 <div>
204 <span className="dim">license</span> MIT
205 </div>
206 <div>
207 <span className="dim">runtime</span> single static binary
208 </div>
209 <div>
210 <span className="dim">platforms</span> macOS · Linux · Windows
211 </div>
212 </div>
213 </div>
214 </section>
215 );
216}
217
218const FEATURES = [
219 {
220 k: "01",
221 title: "Keyboard-native",
222 body: "Read, reply, delete, archive — all from the keyboard. Navigate messages, switch accounts, and jump between folders without touching the mouse.",
223 mono: "j k r d a ↵ esc",
224 },
225 {
226 k: "02",
227 title: "Visual mode batch ops",
228 body: "Enter visual mode to select a range of messages, then delete, archive, or move them all as a single IMAP command.",
229 mono: "v j j j d\n→ deleted 4 messages",
230 },
231 {
232 k: "03",
233 title: "Compose in markdown",
234 body: "Write in the syntax you already know. Headings, lists, fenced code, and tables render cleanly on the other side.",
235 mono: "# subject\n- bullet\n`inline`",
236 },
237 {
238 k: "04",
239 title: "Multi-account, tabbed",
240 body: "IMAP, Gmail, Fastmail, Proton Bridge — all in one window. Switch between them instantly so you never reply from the wrong address.",
241 mono: "← me@andrinoff\n→ drew@floatpane",
242 },
243 {
244 k: "05",
245 title: "Fuzzy filter",
246 body: "Filter across senders, subjects, and bodies in the active view. Results stream in as you type.",
247 mono: "/lena → 3 hits",
248 },
249 {
250 k: "06",
251 title: "Local-first drafts",
252 body: "Every keystroke hits disk before it hits the wire. Close the laptop, open it anywhere, pick up mid-sentence.",
253 mono: "~/.cache/matcha/drafts",
254 },
255 {
256 k: "07",
257 title: "CLI that composes",
258 body: "Pipe errors into apologies. Send from scripts, CI, or cron. `matcha send` does one thing well.",
259 mono: "$ matcha send --to …",
260 },
261 {
262 k: "08",
263 title: "Inline image rendering",
264 body: "Images render inline via iTerm2 or kitty graphics where supported. Toggle with a key. Off by default, always.",
265 mono: "→ ◧ images on",
266 },
267 {
268 k: "09",
269 title: "Full-disk encryption",
270 body: "Encrypt all local data with a password that is never stored — not on disk, not in the keyring. Matcha shows a lock screen on startup. Forget the password and there is no reset.",
271 mono: "matcha is locked\n> ••••••••\nenter: unlock",
272 },
273];
274
275function Features() {
276 return (
277 <section className="features" id="features">
278 <div className="section-head reveal">
279 <div className="section-head-l">
280 <div className="section-eyebrow">§ features</div>
281 <h2 className="section-h2">
282 Everything you'd expect.
283 <br />
284 <span className="dim">And nothing you wouldn't.</span>
285 </h2>
286 </div>
287 <p className="section-head-r">
288 Matcha is opinionated. It won't follow you around the web, won't
289 upsell you on credits, and won't sync your signatures to a SaaS. It
290 reads mail. It writes mail. It stays out of the way.
291 </p>
292 </div>
293 <div className="feature-grid">
294 {FEATURES.map((f, i) => (
295 <article
296 key={f.k}
297 className="feature reveal"
298 style={{ transitionDelay: `${i * 55}ms` }}
299 >
300 <div className="feature-head">
301 <span className="feature-k">{f.k}</span>
302 <span className="feature-dash">——</span>
303 </div>
304 <h3 className="feature-title">{f.title}</h3>
305 <p className="feature-body">{f.body}</p>
306 <pre className="feature-mono">{f.mono}</pre>
307 </article>
308 ))}
309 </div>
310 </section>
311 );
312}
313
314const INSTALL_TABS = {
315 brew: {
316 plat: "macOS · Linux",
317 cmd: "$ brew install floatpane/matcha/matcha\n$ matcha",
318 },
319 winget: {
320 plat: "Windows 10 / 11",
321 cmd: "$ winget install --id=floatpane.matcha\n$ matcha",
322 },
323 scoop: {
324 plat: "Windows",
325 cmd: "$ scoop install matcha\n$ matcha",
326 },
327 snap: { plat: "Ubuntu · Linux", cmd: "$ sudo snap install matcha\n$ matcha" },
328 flatpak: {
329 plat: "Linux",
330 cmd: "$ flatpak install https://matcha.email/matcha.flatpakref\n$ matcha",
331 },
332 aur: { plat: "Arch Linux", cmd: "$ yay -S matcha-client-bin\n$ matcha" },
333 nix: {
334 plat: "NixOS · any Nix",
335 cmd: "$ nix profile install github:floatpane/nix-matcha\n$ matcha",
336 },
337 nixpkgs: {
338 plat: "NixOS · nixpkgs",
339 cmd: "$ nix profile install nixpkgs#matcha\n$ matcha",
340 },
341};
342
343function Install() {
344 const [tab, setTab] = useState("brew");
345 const [meta, setMeta] = useState({
346 version: "0.8.2",
347 date: "apr 23, 2026",
348 size: null,
349 });
350 useEffect(() => {
351 fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
352 .then((r) => (r.ok ? r.json() : null))
353 .then((d) => {
354 if (!d) return;
355 const version = (d.tag_name || "").replace(/^v/, "") || "0.8.2";
356 const date = d.published_at
357 ? new Date(d.published_at)
358 .toLocaleDateString("en-US", {
359 month: "short",
360 day: "numeric",
361 year: "numeric",
362 })
363 .toLowerCase()
364 : "apr 23, 2026";
365 const asset = (d.assets || []).find((a) => a.size) || null;
366 const size = asset
367 ? (asset.size / (1024 * 1024)).toFixed(1) + " MB"
368 : null;
369 setMeta({ version, date, size });
370 })
371 .catch(() => {});
372 }, []);
373 const t = INSTALL_TABS[tab];
374 return (
375 <section className="install" id="install">
376 <div className="section-head reveal">
377 <div className="section-head-l">
378 <div className="section-eyebrow">§ install</div>
379 <h2 className="section-h2">
380 One binary.
381 <br />
382 <span className="dim">Pick your package manager.</span>
383 </h2>
384 </div>
385 <p className="section-head-r">
386 No runtime. Ships natively for macOS, Linux, Windows. Source and
387 issues at{" "}
388 <a
389 href="https://github.com/floatpane/matcha"
390 className="underline-link"
391 >
392 github.com/floatpane/matcha
393 </a>
394 .
395 </p>
396 </div>
397 <div className="install-card reveal" style={{ transitionDelay: "0.15s" }}>
398 <div className="install-tabs">
399 {Object.keys(INSTALL_TABS).map((k) => (
400 <button
401 key={k}
402 onClick={() => setTab(k)}
403 className={"install-tab " + (tab === k ? "active" : "")}
404 >
405 {k}
406 </button>
407 ))}
408 <div className="install-tabs-spacer" />
409 <span className="install-plat">{t.plat}</span>
410 </div>
411 <pre className="install-code">{t.cmd}</pre>
412 <div className="install-foot">
413 <div>
414 <span className="dim">latest</span> {meta.version} · {meta.date}
415 </div>
416 <div>
417 <span className="dim">source</span> github.com/floatpane/matcha
418 </div>
419 </div>
420 </div>
421 </section>
422 );
423}
424
425function CTA() {
426 return (
427 <section className="cta">
428 <div className="cta-inner">
429 <div className="cta-pre reveal">$ _</div>
430 <h2
431 className="cta-h2 reveal"
432 style={{ transitionDelay: "0.12s" }}
433 >
434 Your inbox is waiting
435 <br />
436 in the terminal.
437 </h2>
438 <div
439 className="cta-row reveal"
440 style={{ transitionDelay: "0.24s" }}
441 >
442 <a href="#install" className="btn btn-primary btn-lg">
443 install matcha
444 </a>
445 <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
446 read the docs →
447 </a>
448 </div>
449 </div>
450 </section>
451 );
452}
453
454function Footer() {
455 return (
456 <footer className="footer">
457 <div className="footer-top">
458 <div className="footer-brand">
459 <MatchaWordmark />
460 <p className="footer-tag">
461 a keyboard-native email client.
462 <br />
463 made with care by floatpane.
464 </p>
465 </div>
466 <div className="footer-cols">
467 <div>
468 <div className="footer-h">product</div>
469 <a href="#features">features</a>
470 <a href="#install">install</a>
471 <a href="https://github.com/floatpane/matcha/releases">releases</a>
472 </div>
473 <div>
474 <div className="footer-h">resources</div>
475 <a href="https://docs.matcha.email">docs</a>
476 <a href="https://docs.matcha.email/Configuration">config</a>
477 <a href="https://docs.matcha.email/Features/CLI">cli</a>
478 <a href="https://github.com/floatpane/matcha/blob/master/SECURITY.md">
479 security
480 </a>
481 </div>
482 <div>
483 <div className="footer-h">community</div>
484 <a href="https://github.com/floatpane/matcha">github</a>
485 <a href="https://discord.gg/RxNrJgfatk">discord</a>
486 <a href="https://fosstodon.org/@floatpane">mastodon</a>
487 </div>
488 <div>
489 <div className="footer-h">floatpane</div>
490 <a href="https://floatpane.com">website</a>
491 <a href="mailto:us@floatpane.com">contact</a>
492 <a href="mailto:support@floatpane.com">support</a>
493 </div>
494 </div>
495 </div>
496 <div className="footer-bot">
497 <div className="footer-copy">
498 <FloatpaneMark size={16} />
499 <span>
500 © {new Date().getFullYear()} floatpane · MIT licensed · no trackers
501 on this page
502 </span>
503 </div>
504 </div>
505 </footer>
506 );
507}
508
509function App() {
510 useScrollReveal();
511 useMouseGlow();
512 return (
513 <div className="site">
514 <TopNav />
515 <Hero />
516 <Features />
517 <Install />
518 <CTA />
519 <Footer />
520 </div>
521 );
522}
523
524window.MatchaApp = App;