I've been writing in Markdown for years — documentation, blog posts, notes, project specs. I tried dozens of editors. Some were fast but ugly. Some were beautiful but Electron-heavy. Some locked my files in proprietary formats or demanded a cloud subscription for basic features.
So I built my own.
CrabPad is a desktop Markdown editor built with Tauri (Rust backend + React/TypeScript frontend). It's local-first, keyboard-driven, free, and runs on macOS, Windows, and Linux.
In this post I want to share the technical decisions, trade-offs, and lessons learned from building it.
Why Tauri over Electron?
The decision was easy. Tauri gives you:
- Rust backend — memory-safe, fast, tiny binaries
- System webview — no bundled Chromium, so the app weighs ~15MB instead of 150MB+
- Native OS integration — file dialogs, menus, auto-update, CLI args — all built in
- Universal macOS binary — ARM64 + x86_64 in one package, out of the box
The trade-off? You're at the mercy of each platform's webview quirks. Safari-based WebKit on macOS behaves differently than WebView2 on Windows. CSS rendering, font smoothing, keyboard events — all slightly different. But for a text editor, the savings in memory and startup time are absolutely worth it.
Architecture
┌──────────────────────────────┐
│ React + TypeScript │ ← UI, editor, preview, tabs
│ TailwindCSS │
├──────────────────────────────┤
│ Tauri IPC (invoke) │ ← commands bridge
├──────────────────────────────┤
│ Rust backend │ ← Markdown parsing, file I/O,
│ pulldown-cmark │ search, settings persistence
└──────────────────────────────┘
The frontend handles all UI state — open files, tabs, preview, keybindings. The backend handles everything that touches the filesystem or needs performance: Markdown parsing, file read/write, workspace search, and user settings persistence.
Communication happens through Tauri's invoke() — essentially async IPC calls that feel like calling a local function.
Markdown rendering: more than you'd think
I started with pulldown-cmark on the Rust side for the core GFM parsing (tables, footnotes, strikethrough, task lists). But modern Markdown needs way more than that.
I ended up building a preprocessing pipeline in Rust that runs before pulldown-cmark:
-
Emoticon → Emoji shortcode replacement (
:-)→ 😊) -
Emoji shortcodes via
gh-emojicrate -
Superscript/subscript (
^sup^,~sub~) via regex -
Insert/Mark (
++inserted++,==highlighted==) -
Abbreviation definitions (
*[HTML]: HyperText Markup Language) -
Inline footnotes (
^[This is an inline footnote]) -
Definition lists (term +
: definition) -
GitHub-style admonitions (
> [!NOTE],> [!WARNING]) -
Custom containers (
::: warning ... :::)
Then pulldown-cmark parses the result. On the frontend, KaTeX handles math and Mermaid renders diagrams from fenced code blocks.
Lesson learned: Don't try to extend a Markdown parser at the AST level if all you need is text preprocessing. Regex-based passes before parsing are pragmatic and fast enough for real-time preview.
Keyboard-first design
Every feature in CrabPad is accessible without a mouse. The core shortcuts:
| Action | Shortcut |
|---|---|
| Command Palette | CMD+Shift+P |
| Toggle Preview | CMD+P |
| Toggle Outline | CMD+Shift+E |
| Zen Mode | CMD+Alt+Z |
| Global Search | CMD+Shift+F |
| Settings | CMD+, |
Building the Command Palette was straightforward — a fuzzy-search input over a list of registered commands. The hard part was keyboard event handling on macOS.
e.key is unreliable with modifier keys. Press CMD+Alt+Z on macOS and e.key becomes Ω, not Z. The fix: fall back to e.code (physical key position) when e.key doesn't match:
function checkShortcut(e: KeyboardEvent, shortcut: string): boolean {
const parts = shortcut.toLowerCase().split('+');
const key = parts[parts.length - 1];
const keyMatch = e.key.toLowerCase() === key
|| e.code.toLowerCase() === `key${key}`;
return keyMatch
&& parts.includes('cmd') === (e.metaKey || e.ctrlKey)
&& parts.includes('shift') === e.shiftKey
&& parts.includes('alt') === e.altKey;
}
Another gotcha: if you have a shortcut recorder (for custom keybindings) and a global keyboard handler, they fight each other. My solution was a global flag window.__crabpadRecordingShortcut — the recorder sets it to true, the global handler checks it and bails out. Not elegant, but battle-tested.
Zen Mode
Zen Mode hides everything — tabs, sidebar, status bar — leaving only the editor centered on screen. It sounds simple, but the devil is in the details:
- State must persist across sessions (user closes app in Zen Mode, reopens → still in Zen Mode)
- Escape should exit Zen Mode, but also close modals — so you need a priority chain
- The editor width needs to be constrained for readability (
max-width+justify-center)
The state persistence is handled by saving all UI preferences to a settings.json in Tauri's app data directory. This file survives app updates — unlike localStorage which lives inside the WebView storage.
Auto-update with progress
Tauri has a built-in updater plugin. You publish a signed JSON manifest (update.json) at a public URL, and the app checks it on startup.
My first implementation used native OS dialogs for the update flow. It worked, but the UX was terrible — click "Download", see a system alert saying "please wait", then... nothing. The download happened in the background with progress only visible in console.log.
I replaced it with a custom React modal that shows every stage:
- Checking → spinner
- Available → version, release notes, "Download & Install" button
-
Downloading → progress bar with MB counter (
3.2 MB of 12.8 MB) -
Ready → "Restart Now" button (uses
@tauri-apps/plugin-processfor relaunch) - Error → message + link to contact form
The CI/CD pipeline (GitHub Actions) builds for all 3 platforms, signs the bundles with minisign, generates update.json, deploys binaries to a VPS, and updates the Homebrew Cask — all automatically on tag push.
Local-first: a conscious choice
CrabPad has no backend, no accounts, no sync. Files are plain .md on disk.
This isn't just a privacy feature — it's an architectural simplification. No auth flows, no conflict resolution, no server costs, no GDPR headaches. The app works offline, files load instantly, and if CrabPad disappears tomorrow, your documents are still standard Markdown.
The only network request the app makes is the optional update check to crabpad.app/update.json.
Distribution
Getting a desktop app to users in 2026 is harder than it should be:
-
macOS: Unsigned apps trigger Gatekeeper warnings. Apple Developer Program costs $99/year. My workaround: distribute via Homebrew (
brew install --cask LukaszOleniuk/tap/crabpad), which strips the quarantine attribute automatically. - Windows: NSIS installer works well, but SmartScreen may flag unknown publishers.
-
Linux: AppImage for universal compatibility,
.debfor Debian/Ubuntu. No Flatpak or Snap yet.
Lesson learned: Homebrew Cask is the single best distribution channel for indie macOS apps without Apple notarization. Zero friction for the user.
What's next
- Windows/Linux testing and polish
- Plugin system for custom renderers
- Export to PDF/HTML
- Vim keybinding mode
- Exploring code signing / notarization
Try it
Website: crabpad.app
Homebrew (macOS):
brew install --cask LukaszOleniuk/tap/crabpad
Changelog: crabpad.app/changelog
I'd genuinely love feedback — what works, what doesn't, what's missing. You can reach me via the contact form or find me on LinkedIn.
Thanks for reading.


Top comments (0)