Build a Clipboard Manager with Tauri + Tailwind CSS | Desktop App Tutorial
Video: Build a Clipboard Manager with Tauri + Tailwind CSS | Desktop App Tutorial by Taught by Celeste AI - AI Coding Coach
Tray icon + clipboard plugin + a
Vec<ClipEntry>history. The kind of utility app Tauri excels at.
A clipboard manager keeps a history of what you've copied. Press a global shortcut, see recent items, click to paste it back. Useful enough that there are paid macOS apps for it. Building one in Tauri is a couple of plugins, a tray icon, and a list of strings.
What we are building
A small Tauri app that:
- Watches the system clipboard for changes.
- Records each new entry in a history (capped at, say, 50 items).
- Lives in the system tray; clicking the tray opens a window with the history.
- Click any entry to copy it back to the clipboard.
Plugins
cargo add tauri-plugin-clipboard-manager
npm install @tauri-apps/plugin-clipboard-manager
Register:
.plugin(tauri_plugin_clipboard_manager::init())
Capability:
"permissions": [
"core:default",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
Reading and writing the clipboard
import { readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
const current = await readText();
await writeText("Hello clipboard");
Two functions. Read returns the current text on the clipboard (or null if it's not text — images, files, etc.). Write replaces the clipboard contents.
Polling the clipboard
The clipboard plugin provides read/write but doesn't notify on change. Poll instead — every 500ms or 1s, read and compare to the last value:
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_clipboard_manager::ClipboardExt;
pub fn start_clipboard_watcher(app: AppHandle) {
std::thread::spawn(move || {
let mut last_value: Option<String> = None;
loop {
std::thread::sleep(Duration::from_millis(500));
if let Ok(text) = app.clipboard().read_text() {
if Some(&text) != last_value.as_ref() {
last_value = Some(text.clone());
let _ = app.emit("clipboard-changed", text);
}
}
}
});
}
In the setup callback:
.setup(|app| {
start_clipboard_watcher(app.handle().clone());
Ok(())
})
The frontend listens for clipboard-changed events and updates the history.
The history state
const [history, setHistory] = useState<string[]>([]);
useEffect(() => {
const unlisten = listen<string>("clipboard-changed", (event) => {
setHistory((prev) => {
const next = [event.payload, ...prev.filter(x => x !== event.payload)];
return next.slice(0, 50);
});
});
return () => { unlisten.then((fn) => fn()); };
}, []);
Each new entry goes to the front. We dedupe (filter out previous occurrences of the same text). We cap at 50 items.
For persistence across sessions, save the history to disk via the fs plugin or via Tauri's Storage.
The history UI
<ul className="divide-y">
{history.map((entry, i) => (
<li
key={i}
onClick={() => writeText(entry)}
className="p-3 hover:bg-slate-100 cursor-pointer truncate"
title={entry}
>
{entry.slice(0, 80)}
</li>
))}
</ul>
Click an item — it goes back on the clipboard. Long entries truncate visually with truncate; the full text shows on hover via title.
Tray icon
We covered the tray in the System Tray lesson. Add it here so the user can summon the clipboard window:
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: tauri::tray::MouseButton::Left, .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Click the tray icon, the clipboard window appears.
Global shortcut
For "press Cmd+Shift+V to open the clipboard," use the global-shortcut plugin:
cargo add tauri-plugin-global-shortcut
npm install @tauri-apps/plugin-global-shortcut
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
.plugin(tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state == ShortcutState::Pressed {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.with_shortcuts([Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::KeyV)])?
.build())
Now Cmd+Shift+V (or Ctrl+Shift+V on Windows/Linux) opens the clipboard window from anywhere.
Hide on click outside
When the user clicks a clipboard entry to paste it, the window should hide so they can immediately use the pasted text:
async function paste(entry: string) {
await writeText(entry);
await getCurrentWindow().hide();
}
Same for clicking the empty area or pressing Escape:
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") getCurrentWindow().hide();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
Search
Once the history has items, a search box helps:
const [query, setQuery] = useState("");
const filtered = history.filter((h) => h.toLowerCase().includes(query.toLowerCase()));
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search clipboard..."
className="w-full p-2 border-b"
/>
For thousands of entries, fuzzy search (e.g., fuse.js) is nicer than substring matching. For 50 items, substring is fine.
Persistence
Save the history to disk with the fs plugin every time it changes:
useEffect(() => {
writeTextFile("clipboard-history.json", JSON.stringify(history), {
baseDir: BaseDirectory.AppData,
});
}, [history]);
Load on startup:
useEffect(() => {
(async () => {
try {
const text = await readTextFile("clipboard-history.json", {
baseDir: BaseDirectory.AppData,
});
setHistory(JSON.parse(text));
} catch {
// first run, no history file
}
})();
}, []);
Common mistakes
Polling too aggressively. A 50ms poll wastes CPU. 500ms is usually invisible to the user.
Not deduping. Without dedup, every Ctrl+V cycles the same entry to the top of the list — visually noisy.
Storing huge clipboard contents. A user copies a 10MB CSV; suddenly your history is 10MB. Truncate large entries or skip them.
Cleartext sensitive data. Passwords, credit cards — your history captures them. For a real product, add a "clear history" button and consider an opt-out for sensitive apps.
Forgetting Linux. Linux's clipboard model is different (PRIMARY vs CLIPBOARD selections). Test on Linux explicitly.
What's next
Next lesson: HTTP requests. Calling external APIs from a Tauri app. Building a "quote of the day" mini-app to demonstrate the pattern.
Recap
tauri-plugin-clipboard-manager for read/write. Poll on a thread to detect changes; emit events to the frontend. Combine with a tray icon and a global shortcut for a complete utility app. Persist the history file via the fs plugin.
Next: HTTP requests.