Back to Blog

Build a Clipboard Manager with Tauri + Tailwind CSS | Desktop App Tutorial

Sandy LaneSandy Lane

Video: Build a Clipboard Manager with Tauri + Tailwind CSS | Desktop App Tutorial by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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:

  1. Watches the system clipboard for changes.
  2. Records each new entry in a history (capped at, say, 50 items).
  3. Lives in the system tray; clicking the tray opens a window with the history.
  4. 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.