1const { useState, useEffect } = React;
2
3function FloatpaneMark({ size = 20 }) {
4 return (
5 <img
6 src="../assets/floatpane.png"
7 alt="Floatpane logo"
8 className="wm-logo"
9 height={size}
10 width={size}
11 />
12 );
13}
14
15function MatchaWordmark() {
16 const [version, setVersion] = useState("v0.8.2");
17 useEffect(() => {
18 // floatpane/matcha repo ā fetch latest release tag
19 fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
20 .then((r) => (r.ok ? r.json() : null))
21 .then((d) => {
22 if (d && d.tag_name)
23 setVersion(
24 d.tag_name.startsWith("v") ? d.tag_name : "v" + d.tag_name,
25 );
26 })
27 .catch(() => {});
28 }, []);
29 return (
30 <div className="wordmark">
31 <img
32 src="../assets/logo-transparent.png"
33 alt="Matcha logo"
34 className="wm-logo"
35 height={24}
36 width={24}
37 />
38 <span className="wm-name">matcha</span>
39 <span className="wm-dim">ā {version}</span>
40 </div>
41 );
42}
43
44function TopNav() {
45 const [stars, setStars] = useState(null);
46 useEffect(() => {
47 fetch("https://api.github.com/repos/floatpane/matcha")
48 .then((r) => (r.ok ? r.json() : null))
49 .then((d) => {
50 if (d && typeof d.stargazers_count === "number") {
51 const n = d.stargazers_count;
52 setStars(n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
53 }
54 })
55 .catch(() => {});
56 }, []);
57 return (
58 <header className="nav">
59 <a href="#top" className="nav-brand">
60 <MatchaWordmark />
61 </a>
62 <nav className="nav-links">
63 <a href="#features">Features</a>
64 <a href="#keys">Keybinds</a>
65 <a href="#install">Install</a>
66 <a href="https://docs.matcha.email">Docs ā</a>
67 <a href="https://github.com/floatpane/matcha" className="nav-github">
68 <span>GitHub</span>
69 {stars && <span className="nav-star">ā
{stars}</span>}
70 </a>
71 </nav>
72 <div className="nav-right">
73 <a href="#install" className="btn btn-ghost">
74 install
75 </a>
76 </div>
77 </header>
78 );
79}
80
81function Hero({ datasetKey, setDatasetKey }) {
82 const [pressed, setPressed] = useState(false);
83 const TUI = window.MatchaTUI;
84 return (
85 <section className="hero" id="top">
86 <div className="hero-copy">
87 <div className="hero-eyebrow">
88 <span className="dot-live" />
89 <span>by floatpane Ā· local-first Ā· secure Ā· no telemetry</span>
90 </div>
91 <h1 className="hero-h1">
92 A powerful, feature-rich
93 <br />
94 email client <em>for your terminal.</em>
95 </h1>
96 <p className="hero-sub">
97 Matcha is a modern TUI email client for people who live in the shell.
98 Vim keybindings, PGP, IMAP multi-account, markdown composing,
99 visual-mode batch ops, and a CLI that speaks your language.
100 </p>
101 <div className="hero-cta">
102 <a href="#install" className="btn btn-primary">
103 <span>Install</span>
104 <span className="btn-k">āµ</span>
105 </a>
106 <a href="https://docs.matcha.email" className="btn btn-ghost">
107 <span>Read the docs</span>
108 <span className="btn-k">ā</span>
109 </a>
110 </div>
111 <div className="hero-meta">
112 <div>
113 <span className="dim">license</span> MIT
114 </div>
115 <div>
116 <span className="dim">runtime</span> single static binary
117 </div>
118 <div>
119 <span className="dim">platforms</span> macOS Ā· Linux Ā· Windows
120 </div>
121 </div>
122 </div>
123
124 <div className="hero-demo">
125 <div className="hero-demo-chrome">
126 <div className="hero-demo-label">
127 <span className="dim">demo Ā·</span>
128 <button
129 className={"demo-swap " + (datasetKey === "default" ? "on" : "")}
130 onClick={() => setDatasetKey("default")}
131 >
132 drew's inbox
133 </button>
134 <button
135 className={"demo-swap " + (datasetKey === "dev" ? "on" : "")}
136 onClick={() => setDatasetKey("dev")}
137 >
138 floatpane dev
139 </button>
140 <button
141 className={"demo-swap " + (datasetKey === "personal" ? "on" : "")}
142 onClick={() => setDatasetKey("personal")}
143 >
144 personal
145 </button>
146 </div>
147 <div className="hero-demo-hint">
148 {pressed ? (
149 <span>ā keyboard live</span>
150 ) : (
151 <span className="dim">
152 click Ā· <kbd>j</kbd>
153 <kbd>k</kbd> Ā· <kbd>tab</kbd> Ā· <kbd>āµ</kbd>
154 </span>
155 )}
156 </div>
157 </div>
158 {TUI && (
159 <TUI datasetKey={datasetKey} onKeyPressed={() => setPressed(true)} />
160 )}
161 </div>
162 </section>
163 );
164}
165
166const FEATURES = [
167 {
168 k: "01",
169 title: "Email, the way you move",
170 body: "Read, reply, delete, archive ā all from the home row. j/k to move, h/l between accounts, tab between folders. No mouse, no modals.",
171 mono: "j k h l r d a āµ",
172 },
173 {
174 k: "02",
175 title: "Visual mode, for real",
176 body: "Press v to enter Vim-style multi-select. Expand with j/k, then d, a, or m to run batch ops as a single IMAP command.",
177 mono: "v j j j d\nā deleted 4 messages",
178 },
179 {
180 k: "03",
181 title: "Compose in markdown",
182 body: "Write in the syntax you already know. Headings, lists, fenced code, and tables render cleanly on the other side.",
183 mono: "# subject\n- bullet\n`inline`",
184 },
185 {
186 k: "04",
187 title: "Multi-account, tabbed",
188 body: "IMAP, Gmail, Fastmail, Proton Bridge ā all tabbed in one window. h and l switch between them so you never reply from the wrong address.",
189 mono: "ā me@andrinoff\nā drew@floatpane",
190 },
191 {
192 k: "05",
193 title: "Fuzzy filter",
194 body: "Press / to fuzzy-filter across senders, subjects, and bodies in the active view. Results stream in as you type.",
195 mono: "/lena ā 3 hits",
196 },
197 {
198 k: "06",
199 title: "Local-first drafts",
200 body: "Every keystroke hits disk before it hits the wire. Close the laptop, open it anywhere, pick up mid-sentence. Esc saves.",
201 mono: "~/.cache/matcha/drafts",
202 },
203 {
204 k: "07",
205 title: "CLI that composes",
206 body: "Pipe errors into apologies. Send from scripts, CI, or cron. `matcha send` does one thing well.",
207 mono: "$ matcha send --to ā¦",
208 },
209 {
210 k: "08",
211 title: "AI, on your terms",
212 body: "Rewrite drafts with the model of your choice. Let agents send on your behalf ā with strict scopes and an audit log.",
213 mono: "alt + r: make it more formal",
214 },
215 {
216 k: "09",
217 title: "Smart image rendering",
218 body: "Images render inline via iterm2 or kitty-graphics where supported. Toggle with i. Off by default, always.",
219 mono: "i ā ā§ images on",
220 },
221];
222
223function Features() {
224 return (
225 <section className="features" id="features">
226 <div className="section-head">
227 <div className="section-head-l">
228 <div className="section-eyebrow">§ features</div>
229 <h2 className="section-h2">
230 Everything you'd expect.
231 <br />
232 <span className="dim">And nothing you wouldn't.</span>
233 </h2>
234 </div>
235 <p className="section-head-r">
236 Matcha is opinionated. It won't follow you around the web, won't
237 upsell you on AI credits, and won't sync your signatures to a SaaS. It
238 reads mail. It writes mail. It stays out of the way.
239 </p>
240 </div>
241 <div className="feature-grid">
242 {FEATURES.map((f) => (
243 <article key={f.k} className="feature">
244 <div className="feature-head">
245 <span className="feature-k">{f.k}</span>
246 <span className="feature-dash">āā</span>
247 </div>
248 <h3 className="feature-title">{f.title}</h3>
249 <p className="feature-body">{f.body}</p>
250 <pre className="feature-mono">{f.mono}</pre>
251 </article>
252 ))}
253 </div>
254 </section>
255 );
256}
257
258function Keybinds() {
259 const rows = [
260 {
261 g: "motion",
262 items: [
263 ["j / k", "next / prev message"],
264 ["ā / ā", "next / prev message"],
265 ["h / l", "prev / next account"],
266 ["ā / ā", "prev / next account"],
267 ["tab", "next folder"],
268 ["shift-tab", "prev folder"],
269 ],
270 },
271 {
272 g: "inbox",
273 items: [
274 ["āµ", "open email"],
275 ["r", "refresh"],
276 ["d", "delete"],
277 ["a", "archive"],
278 ["/", "filter"],
279 ["v", "visual mode"],
280 ["esc", "back / main menu"],
281 ],
282 },
283 {
284 g: "visual mode",
285 items: [
286 ["v", "enter visual mode"],
287 ["j / k", "expand selection"],
288 ["d", "delete all selected"],
289 ["a", "archive all selected"],
290 ["m", "move to folder"],
291 ["v / esc", "exit visual mode"],
292 ],
293 },
294 {
295 g: "email view",
296 items: [
297 ["j / k", "scroll body"],
298 ["r", "reply"],
299 ["d", "delete"],
300 ["a", "archive"],
301 ["tab", "focus attachments"],
302 ["i", "toggle images"],
303 ["esc", "back to inbox"],
304 ],
305 },
306 {
307 g: "attachments",
308 items: [
309 ["j / k", "navigate"],
310 ["āµ", "download & open"],
311 ["tab / esc", "back to body"],
312 ],
313 },
314 {
315 g: "composer",
316 items: [
317 ["tab / shift-tab", "navigate fields"],
318 ["āµ on From", "select account"],
319 ["āµ on Attachment", "open file picker"],
320 ["āµ on Send", "send email"],
321 ["ā / ā", "contact suggestions"],
322 ["esc", "save draft & exit"],
323 ],
324 },
325 ];
326 return (
327 <section className="keybinds" id="keys">
328 <div className="section-head">
329 <div className="section-head-l">
330 <div className="section-eyebrow">§ keybinds</div>
331 <h2 className="section-h2">
332 Vim-native.
333 <br />
334 <span className="dim">Home row to inbox zero.</span>
335 </h2>
336 </div>
337 <p className="section-head-r">
338 Every binding is documented at{" "}
339 <a href="https://docs.matcha.email" className="underline-link">
340 docs.matcha.email
341 </a>
342 . Muscle-memory for vimmers, learnable for everyone else.
343 </p>
344 </div>
345 <div className="keybinds-grid">
346 {rows.map((row) => (
347 <div key={row.g} className="keybinds-col">
348 <div className="keybinds-col-head">āā {row.g} āā</div>
349 {row.items.map(([k, label]) => (
350 <div key={k} className="keybind-row">
351 <span className="keybind-k">
352 <kbd>{k}</kbd>
353 </span>
354 <span className="keybind-dots">{"Ā·".repeat(26)}</span>
355 <span className="keybind-label">{label}</span>
356 </div>
357 ))}
358 </div>
359 ))}
360 </div>
361 </section>
362 );
363}
364
365const INSTALL_TABS = {
366 brew: {
367 plat: "macOS Ā· Linux",
368 cmd: "$ brew install floatpane/matcha/matcha\n$ matcha",
369 },
370 winget: {
371 plat: "Windows 10 / 11",
372 cmd: "$ winget install --id=floatpane.matcha\n$ matcha",
373 },
374 snap: { plat: "Ubuntu Ā· Linux", cmd: "$ sudo snap install matcha\n$ matcha" },
375 flatpak: {
376 plat: "Linux",
377 cmd: "$ flatpak install https://matcha.email/matcha.flatpakref\n$ matcha",
378 },
379 aur: { plat: "Arch Linux", cmd: "$ yay -S matcha-client-bin\n$ matcha" },
380 nix: {
381 plat: "NixOS Ā· any Nix",
382 cmd: "$ nix profile install github:floatpane/matcha\n$ matcha",
383 },
384};
385
386function Install() {
387 const [tab, setTab] = useState("brew");
388 const [meta, setMeta] = useState({
389 version: "0.8.2",
390 date: "apr 23, 2026",
391 size: null,
392 });
393 useEffect(() => {
394 fetch("https://api.github.com/repos/floatpane/matcha/releases/latest")
395 .then((r) => (r.ok ? r.json() : null))
396 .then((d) => {
397 if (!d) return;
398 const version = (d.tag_name || "").replace(/^v/, "") || "0.8.2";
399 const date = d.published_at
400 ? new Date(d.published_at)
401 .toLocaleDateString("en-US", {
402 month: "short",
403 day: "numeric",
404 year: "numeric",
405 })
406 .toLowerCase()
407 : "apr 23, 2026";
408 const asset = (d.assets || []).find((a) => a.size) || null;
409 const size = asset
410 ? (asset.size / (1024 * 1024)).toFixed(1) + " MB"
411 : null;
412 setMeta({ version, date, size });
413 })
414 .catch(() => {});
415 }, []);
416 const t = INSTALL_TABS[tab];
417 return (
418 <section className="install" id="install">
419 <div className="section-head">
420 <div className="section-head-l">
421 <div className="section-eyebrow">§ install</div>
422 <h2 className="section-h2">
423 One binary.
424 <br />
425 <span className="dim">Pick your package manager.</span>
426 </h2>
427 </div>
428 <p className="section-head-r">
429 No runtime. Ships natively for macOS, Linux, Windows. Source and
430 issues at{" "}
431 <a
432 href="https://github.com/floatpane/matcha"
433 className="underline-link"
434 >
435 github.com/floatpane/matcha
436 </a>
437 .
438 </p>
439 </div>
440 <div className="install-card">
441 <div className="install-tabs">
442 {Object.keys(INSTALL_TABS).map((k) => (
443 <button
444 key={k}
445 onClick={() => setTab(k)}
446 className={"install-tab " + (tab === k ? "active" : "")}
447 >
448 {k}
449 </button>
450 ))}
451 <div className="install-tabs-spacer" />
452 <span className="install-plat">{t.plat}</span>
453 </div>
454 <pre className="install-code">{t.cmd}</pre>
455 <div className="install-foot">
456 <div>
457 <span className="dim">latest</span> {meta.version} Ā· {meta.date}
458 </div>
459 <div>
460 <span className="dim">source</span> github.com/floatpane/matcha
461 </div>
462 </div>
463 </div>
464 </section>
465 );
466}
467
468function CTA() {
469 return (
470 <section className="cta">
471 <div className="cta-inner">
472 <div className="cta-pre">$ _</div>
473 <h2 className="cta-h2">
474 Your inbox is waiting
475 <br />
476 in the terminal.
477 </h2>
478 <div className="cta-row">
479 <a href="#install" className="btn btn-primary btn-lg">
480 make your emails secure
481 </a>
482 <a href="https://docs.matcha.email" className="btn btn-ghost btn-lg">
483 read the docs ā
484 </a>
485 </div>
486 </div>
487 </section>
488 );
489}
490
491function Footer() {
492 return (
493 <footer className="footer">
494 <div className="footer-top">
495 <div className="footer-brand">
496 <MatchaWordmark />
497 <p className="footer-tag">
498 a modern TUI email client.
499 <br />
500 made with care by floatpane.
501 </p>
502 </div>
503 <div className="footer-cols">
504 <div>
505 <div className="footer-h">product</div>
506 <a href="#features">features</a>
507 <a href="#keys">keybinds</a>
508 <a href="#install">install</a>
509 <a href="https://github.com/floatpane/matcha/releases">releases</a>
510 </div>
511 <div>
512 <div className="footer-h">resources</div>
513 <a href="https://docs.matcha.email">docs</a>
514 <a href="https://docs.matcha.email/Configuration">config</a>
515 <a href="https://docs.matcha.email/Features/CLI">cli</a>
516 <a href="https://github.com/floatpane/matcha/blob/master/SECURITY.md">
517 security
518 </a>
519 </div>
520 <div>
521 <div className="footer-h">community</div>
522 <a href="https://github.com/floatpane/matcha">github</a>
523 <a href="https://discord.gg/RxNrJgfatk">discord</a>
524 <a href="https://fosstodon.org/@floatpane">mastodon</a>
525 </div>
526 <div>
527 <div className="footer-h">floatpane</div>
528 <a href="https://floatpane.com">website</a>
529 <a href="mailto:us@floatpane.com">contact</a>
530 <a href="mailto:support@floatpane.com">support</a>
531 </div>
532 </div>
533 </div>
534 <div className="footer-bot">
535 <div className="footer-copy">
536 <FloatpaneMark size={16} />
537 <span>
538 Ā© {new Date().getFullYear()} floatpane Ā· MIT licensed Ā· no trackers
539 on this page
540 </span>
541 </div>
542 </div>
543 </footer>
544 );
545}
546
547// ---------- Tweaks ----------
548function Tweaks({ datasetKey, setDatasetKey, visible }) {
549 if (!visible) return null;
550 return (
551 <div className="tweaks">
552 <div className="tweaks-head">Tweaks</div>
553 <div className="tweaks-sub">Demo content</div>
554 {Object.entries({
555 default: "drew's inbox",
556 dev: "floatpane dev",
557 personal: "personal",
558 }).map(([k, label]) => (
559 <button
560 key={k}
561 onClick={() => setDatasetKey(k)}
562 className={"tweaks-opt " + (datasetKey === k ? "on" : "")}
563 >
564 <span className="tweaks-dot">{datasetKey === k ? "ā" : "ā"}</span>
565 <span>{label}</span>
566 </button>
567 ))}
568 </div>
569 );
570}
571
572function App() {
573 const [datasetKey, setDatasetKey] = useState(() => {
574 try {
575 return localStorage.getItem("matcha-dataset") || "default";
576 } catch {
577 return "default";
578 }
579 });
580 const [tweaksVisible, setTweaksVisible] = useState(false);
581
582 useEffect(() => {
583 try {
584 localStorage.setItem("matcha-dataset", datasetKey);
585 } catch {}
586 }, [datasetKey]);
587
588 useEffect(() => {
589 const onMsg = (e) => {
590 const d = e.data || {};
591 if (d.type === "__activate_edit_mode") setTweaksVisible(true);
592 if (d.type === "__deactivate_edit_mode") setTweaksVisible(false);
593 };
594 window.addEventListener("message", onMsg);
595 window.parent.postMessage({ type: "__edit_mode_available" }, "*");
596 return () => window.removeEventListener("message", onMsg);
597 }, []);
598
599 useEffect(() => {
600 window.parent.postMessage(
601 { type: "__edit_mode_set_keys", edits: { datasetKey } },
602 "*",
603 );
604 }, [datasetKey]);
605
606 return (
607 <div className="site">
608 <TopNav />
609 <Hero datasetKey={datasetKey} setDatasetKey={setDatasetKey} />
610 <Features />
611 <Keybinds />
612 <Install />
613 <CTA />
614 <Footer />
615 <Tweaks
616 datasetKey={datasetKey}
617 setDatasetKey={setDatasetKey}
618 visible={tweaksVisible}
619 />
620 </div>
621 );
622}
623
624window.MatchaApp = App;