Skip to main content
Technology & EngineeringTauri449 lines

Tauri Frontend

Frontend integration with Tauri 2.0 including React, Vue, Svelte, and Solid frameworks, Vite configuration, asset handling, window management, multiple windows, and webview communication.

Quick Summary16 lines
You are an expert in integrating modern frontend frameworks with Tauri 2.0, including window management, asset handling, and cross-webview communication.

## Key Points

- **Using browser-specific APIs without checks**: `navigator.bluetooth`, `window.showOpenFilePicker`, and similar APIs may not exist in the webview. Use Tauri plugins instead.
- **Ignoring cross-webview rendering differences**: WebKit (macOS) and WebView2 (Windows) render CSS differently. Test on all platforms.
- **Large synchronous IPC calls on render**: Calling `invoke` in a render loop or useEffect without debouncing causes excessive IPC overhead.
- **Not cleaning up event listeners**: Tauri event listeners (`listen()`) return unsubscribe functions. Failing to call them causes memory leaks.

## Quick Example

```bash
npm create tauri-app@latest my-react-app -- --template react-ts
```
skilldb get tauri-skills/Tauri FrontendFull skill: 449 lines
Paste into your CLAUDE.md or agent config

Tauri Frontend — Webview UI Integration

You are an expert in integrating modern frontend frameworks with Tauri 2.0, including window management, asset handling, and cross-webview communication.

Core Philosophy

Tauri's frontend is a webview, not a browser. It runs your HTML/CSS/JS directly without a bundled browser engine. This means your app shares the system's webview implementation -- WebView2 on Windows, WebKit on macOS and Linux. The practical impact: your CSS and JS must work across these engines, not just Chrome.

Use Vite as your build tool. Tauri's toolchain is optimized for Vite, and the dev experience (HMR, fast rebuilds) is excellent. Pick whatever frontend framework you prefer -- React, Vue, Svelte, Solid -- Tauri is agnostic. The framework only runs in the webview; it has no special integration with the Rust backend beyond the IPC invoke system.

Keep your frontend lightweight. Unlike web apps where bundle size matters for network transfer, in Tauri the bundle is local. But webview memory and startup time still matter, especially on lower-end machines. Avoid loading massive JavaScript bundles at startup -- use code splitting and lazy loading for secondary views.

Anti-Patterns

  • Using browser-specific APIs without checks: navigator.bluetooth, window.showOpenFilePicker, and similar APIs may not exist in the webview. Use Tauri plugins instead.
  • Ignoring cross-webview rendering differences: WebKit (macOS) and WebView2 (Windows) render CSS differently. Test on all platforms.
  • Large synchronous IPC calls on render: Calling invoke in a render loop or useEffect without debouncing causes excessive IPC overhead.
  • Not cleaning up event listeners: Tauri event listeners (listen()) return unsubscribe functions. Failing to call them causes memory leaks.

Vite Configuration

Standard Vite Config for Tauri

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],

  // Prevent Vite from obscuring Rust errors
  clearScreen: false,

  // Tauri expects a fixed port, fail if it's not available
  server: {
    port: 5173,
    strictPort: true,
    watch: {
      // Tell Vite to ignore watching src-tauri
      ignored: ["**/src-tauri/**"],
    },
  },

  // Env variables that start with TAURI_ are exposed to the frontend
  envPrefix: ["VITE_", "TAURI_"],

  build: {
    // Tauri uses Chromium on Windows and WebKit on macOS/Linux
    target: process.env.TAURI_PLATFORM === "windows" ? "chrome105" : "safari14",
    // Don't minify for debug builds
    minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
    // Produce sourcemaps for debug builds
    sourcemap: !!process.env.TAURI_DEBUG,
  },
});

React Integration

Project Setup

npm create tauri-app@latest my-react-app -- --template react-ts

Using Invoke in React Components

import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

function FileViewer() {
  const [content, setContent] = useState<string>("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function loadFile(path: string) {
    setLoading(true);
    setError(null);
    try {
      const text = await invoke<string>("read_file", { path });
      setContent(text);
    } catch (e) {
      setError(String(e));
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    // Listen for backend events
    const unlisten = listen<string>("file-changed", (event) => {
      setContent(event.payload);
    });

    return () => {
      unlisten.then((fn) => fn());
    };
  }, []);

  return (
    <div>
      {loading && <p>Loading...</p>}
      {error && <p className="error">{error}</p>}
      <pre>{content}</pre>
    </div>
  );
}

Custom Hook for Tauri Commands

import { useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";

function useCommand<TArgs, TResult>(command: string) {
  const [data, setData] = useState<TResult | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const execute = useCallback(
    async (args?: TArgs) => {
      setLoading(true);
      setError(null);
      try {
        const result = await invoke<TResult>(command, args ?? {});
        setData(result);
        return result;
      } catch (e) {
        setError(String(e));
        throw e;
      } finally {
        setLoading(false);
      }
    },
    [command]
  );

  return { data, error, loading, execute };
}

// Usage
function UserList() {
  const { data: users, loading, execute: fetchUsers } = useCommand<
    { page: number },
    User[]
  >("list_users");

  useEffect(() => {
    fetchUsers({ page: 1 });
  }, []);

  if (loading) return <Spinner />;
  return <ul>{users?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Vue Integration

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";

const items = ref<string[]>([]);
const loading = ref(false);
let unlisten: UnlistenFn;

async function fetchItems() {
  loading.value = true;
  try {
    items.value = await invoke<string[]>("get_items");
  } finally {
    loading.value = false;
  }
}

onMounted(async () => {
  await fetchItems();
  unlisten = await listen<string>("item-added", (event) => {
    items.value.push(event.payload);
  });
});

onUnmounted(() => {
  unlisten?.();
});
</script>

<template>
  <div v-if="loading">Loading...</div>
  <ul v-else>
    <li v-for="item in items" :key="item">{{ item }}</li>
  </ul>
</template>

Svelte Integration

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import { invoke } from "@tauri-apps/api/core";
  import { listen } from "@tauri-apps/api/event";

  let count = 0;
  let unlistenFn: (() => void) | undefined;

  async function increment() {
    count = await invoke<number>("increment");
  }

  onMount(async () => {
    count = await invoke<number>("get_count");
    unlistenFn = await listen<number>("count-changed", (event) => {
      count = event.payload;
    });
  });

  onDestroy(() => {
    unlistenFn?.();
  });
</script>

<button on:click={increment}>Count: {count}</button>

Window Management

Creating Windows from Rust

use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};

#[tauri::command]
async fn open_settings(app: AppHandle) -> Result<(), String> {
    let _settings_window = WebviewWindowBuilder::new(
        &app,
        "settings",
        WebviewUrl::App("settings.html".into()),
    )
    .title("Settings")
    .inner_size(600.0, 400.0)
    .resizable(false)
    .center()
    .build()
    .map_err(|e| e.to_string())?;

    Ok(())
}

Creating Windows from Frontend

import { WebviewWindow } from "@tauri-apps/api/webviewWindow";

const settingsWindow = new WebviewWindow("settings", {
  url: "/settings",
  title: "Settings",
  width: 600,
  height: 400,
  resizable: false,
  center: true,
});

settingsWindow.once("tauri://created", () => {
  console.log("Settings window created");
});

settingsWindow.once("tauri://error", (e) => {
  console.error("Failed to create window:", e);
});

Window Operations

import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

const appWindow = getCurrentWebviewWindow();

// Window controls
await appWindow.minimize();
await appWindow.maximize();
await appWindow.unmaximize();
await appWindow.toggleMaximize();
await appWindow.close();

// Window properties
await appWindow.setTitle("New Title");
await appWindow.setSize(new LogicalSize(800, 600));
await appWindow.setPosition(new LogicalPosition(100, 100));
await appWindow.setFullscreen(true);
await appWindow.setAlwaysOnTop(true);

// Listen for window events
await appWindow.onResized(({ payload: size }) => {
  console.log(`Window resized to ${size.width}x${size.height}`);
});

await appWindow.onCloseRequested(async (event) => {
  const confirmed = await confirm("Are you sure you want to close?");
  if (!confirmed) {
    event.preventDefault();
  }
});

Multi-Window Communication

// From window A: emit to specific window
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";

const targetWindow = await WebviewWindow.getByLabel("settings");
if (targetWindow) {
  await targetWindow.emit("update-config", { theme: "dark" });
}

// From window B: listen for events
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

const currentWindow = getCurrentWebviewWindow();
await currentWindow.listen<{ theme: string }>("update-config", (event) => {
  applyTheme(event.payload.theme);
});

// Broadcast to all windows (from Rust)
// app.emit("global-event", payload)?;

Asset Handling

Static Assets in Frontend

src/
├── assets/
│   ├── images/
│   │   └── logo.png
│   └── fonts/
│       └── inter.woff2
// Vite handles static imports
import logo from "./assets/images/logo.png";

function App() {
  return <img src={logo} alt="Logo" />;
}

Bundled Resources in Rust Side

// tauri.conf.json
{
  "bundle": {
    "resources": ["resources/*"]
  }
}
use tauri::Manager;

#[tauri::command]
async fn load_resource(app: AppHandle) -> Result<String, String> {
    let resource_path = app
        .path()
        .resolve("resources/data.json", tauri::path::BaseDirectory::Resource)
        .map_err(|e| e.to_string())?;
    std::fs::read_to_string(resource_path).map_err(|e| e.to_string())
}

Custom Titlebar (Frameless Window)

// tauri.conf.json
{
  "app": {
    "windows": [
      {
        "decorations": false,
        "width": 1024,
        "height": 768
      }
    ]
  }
}
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

function Titlebar() {
  const appWindow = getCurrentWebviewWindow();

  return (
    <div data-tauri-drag-region className="titlebar">
      <span>My App</span>
      <div className="titlebar-buttons">
        <button onClick={() => appWindow.minimize()}>&#x2013;</button>
        <button onClick={() => appWindow.toggleMaximize()}>&#x25A1;</button>
        <button onClick={() => appWindow.close()}>&#x2715;</button>
      </div>
    </div>
  );
}
.titlebar {
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 12px;
  background: #1a1a2e;
  color: white;
  user-select: none;
}

.titlebar-buttons button {
  background: transparent;
  border: none;
  color: white;
  width: 36px;
  height: 36px;
  cursor: pointer;
}

.titlebar-buttons button:hover {
  background: rgba(255, 255, 255, 0.1);
}

Install this skill directly: skilldb add tauri-skills

Get CLI access →