マウスカーソルのアイコンを変更する

ページ上のカーソルは CSS の cursor で変えるのが確実。setCursorIcon()(権限 core:window:allow-set-cursor-icon)との違い、処理中の待機カーソル、独自画像、非表示の方法を示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約9分 win-024
目次
  1. 前提条件
  2. CSS とウィンドウの API の使い分け
  3. 1. フロントエンドから実装する (TypeScript)
  4. 要素ごとに変える (CSS)
  5. 処理中はアプリ全体を待機カーソルにする
  6. ウィンドウ単位の API で変える
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

押せるカードの上で指の形にする、キャンバスの上で十字にする、処理中は待機カーソルにする、といった変更は、Tauri アプリでも普通の Web ページと同じく CSS の cursor で行うのが基本です。Tauri にも setCursorIcon()(Rust は set_cursor_icon())がありますが、これはウィンドウ単位の指定で、ページの上ではページ側の CSS の指定に負けることがあります。使い分けと、Rust の処理中にアプリ全体を待機カーソルにする方法、カーソルを隠す API の OS ごとの癖をまとめます。

前提条件

CSS の cursor には権限もプラグインも要りません。ウィンドウ単位のカーソルの API はどれも core:default に含まれないので、使うものだけ追加します。

APIできること権限
setCursorIcon()ウィンドウのカーソルの種類を変えるcore:window:allow-set-cursor-icon
setCursorVisible()カーソルを隠す・戻すcore:window:allow-set-cursor-visible
setCursorGrab()カーソルをウィンドウの外に出さないcore:window:allow-set-cursor-grab
setCursorPosition()カーソルを指定した位置へ動かすcore:window:allow-set-cursor-position

現在の位置を読む cursorPosition() だけは core:window:default に含まれます(カーソル座標の取得)。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:window:allow-set-cursor-icon",
    "core:window:allow-set-cursor-visible"
  ]
}

CSS とウィンドウの API の使い分け

やりたいこと使うもの
ボタンやキャンバスの上だけ変えるCSS の cursor
処理中はアプリ全体を待機カーソルにするhtml に付けたクラスと CSS
独自の画像にするCSS の cursor: url(...)
動画の上などでカーソルを消すCSS の cursor: none
ウィンドウの外に出さない・位置を動かすsetCursorGrab() / setCursorPosition()

setCursorIcon() に渡せるのは決まった種類(CursorIcon)だけで、画像は指定できません。

1. フロントエンドから実装する (TypeScript)

要素ごとに変える (CSS)

.card[role="button"] { cursor: pointer; }
canvas.editor { cursor: crosshair; }
.sortable { cursor: grab; }
.sortable.dragging { cursor: grabbing; }
button:disabled { cursor: not-allowed; }

/* 独自の画像。数値はクリック位置(ホットスポット)、最後のキーワードは読み込めないときの予備 */
.pen-tool { cursor: url('/cursors/pen.png') 2 30, crosshair; }

独自の画像は 32×32 px 程度の PNG にし、Vite なら public/cursors/ に置きます(url() の相対パスは CSS ファイルの位置が基準)。予備のキーワード(上の crosshair)を省くと宣言全体が無効になり、大きすぎる画像は無視されることがあります。

CSS の名前と setCursorIcon() の名前は一部が違います。

CSSCursorIcon
pointer'hand'
not-allowed'notAllowed'
ew-resize / ns-resize'ewResize' / 'nsResize'
context-menu'contextMenu'
zoom-in'zoomIn'

処理中はアプリ全体を待機カーソルにする

document.body.style.cursor = 'wait' だけでは、cursor: pointer を付けたボタンや入力欄(文字カーソル)の上で元のカーソルに戻ります。子要素が自分の cursor を持っているからです。ルートにクラスを付け、すべての要素を !important で上書きします。

html.busy,
html.busy * {
  cursor: progress !important;
}

progress は「処理中だが操作はできる」、wait は「終わるまで待ってほしい」を表すので、処理中も操作させるかどうかで選びます。

import { invoke } from '@tauri-apps/api/core';

let pending = 0;

// 処理の間だけ busy クラスを付ける。並行した処理がすべて終わるまで外さない
export async function withBusy<T>(task: () => Promise<T>): Promise<T> {
  pending++;
  document.documentElement.classList.add('busy');
  try {
    return await task();
  } finally {
    if (--pending === 0) document.documentElement.classList.remove('busy'); // 失敗しても必ず戻す
  }
}

document.querySelector('#run')?.addEventListener('click', () => {
  withBusy(() => invoke<number>('heavy_task'))
    .then((n) => console.log('done:', n))
    .catch((e) => console.error(e));
});

ウィンドウ単位の API で変える

import { getCurrentWindow } from '@tauri-apps/api/window';

const win = getCurrentWindow();

// 3 秒間だけウィンドウのカーソルを変えて隠し、元に戻す
export async function windowCursorDemo() {
  await win.setCursorIcon('wait');   // core:window:allow-set-cursor-icon が必要
  await win.setCursorVisible(false); // core:window:allow-set-cursor-visible が必要
  window.setTimeout(() => {
    void win.setCursorVisible(true);
    void win.setCursorIcon('default');
  }, 3000);
}

ページの上では、ページ側の CSS が指定したカーソルが優先されます。setCursorIcon('wait') を呼んでも、マウスを動かした時点で CSS の指定に戻ることがあります。setCursorVisible(false) は OS によって隠れる範囲が違う(後述)ので、ページの中だけ隠すなら CSS の cursor: none が扱いやすいです。

2. バックエンドから実装する (Rust)

Rust の set_cursor_icon() も JS と同じウィンドウ単位の指定です。コマンドの引数を tauri::CursorIcon にすると JS から 'wait' のような名前で受け取れますが、知らない名前はエラーにならず Default として届きます。CSS の 'pointer' を渡しても通常の矢印になるだけです。

トレイやショートカットなど Rust 側から始まる処理では、開始と終了をイベントでページに知らせ、1 章の busy クラスを付け外しします。

use std::time::Duration;
use tauri::{AppHandle, CursorIcon, Emitter};

/// invoke から呼ぶ重い処理の例(カーソルはページの withBusy() で変える)
#[tauri::command]
async fn heavy_task() -> Result<u32, String> {
    tauri::async_runtime::spawn_blocking(|| {
        std::thread::sleep(Duration::from_millis(1500)); // 重い処理の代わりに 1.5 秒待つ
        42
    })
    .await
    .map_err(|e| e.to_string())
}

/// Rust 側から始める処理。開始と終了をイベントでページに知らせる
fn start_sync(app: AppHandle) {
    tauri::async_runtime::spawn(async move {
        let _ = app.emit("busy", true);
        let _ = tauri::async_runtime::spawn_blocking(|| std::thread::sleep(Duration::from_secs(2))).await;
        let _ = app.emit("busy", false); // 処理が失敗しても必ず送る
    });
}

#[tauri::command]
fn sync_now(app: AppHandle) {
    start_sync(app);
}

/// ウィンドウ単位のカーソル。知らない名前は Default として届く
#[tauri::command]
fn set_window_cursor(window: tauri::WebviewWindow, icon: CursorIcon) -> Result<(), String> {
    window.set_cursor_icon(icon).map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![heavy_task, sync_now, set_window_cursor])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Rust のコマンド経由なら、JS 側に core:window:allow-set-cursor-icon は要りません。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

// Rust から届いた開始・終了に合わせて busy クラスを付け外しする
await listen<boolean>('busy', (e) => {
  document.documentElement.classList.toggle('busy', e.payload);
});

await invoke('sync_now'); // 約 2 秒間、ページ全体が待機カーソルになる
await invoke('set_window_cursor', { icon: 'crosshair' });

動作確認

npm run tauri dev で起動し、カードやキャンバスにカーソルを乗せると CSS で指定した形に変わります。#run のボタンを押すと、処理が終わるまでボタンや入力欄の上も含めてページ全体が progress のカーソルになり、約 1.5 秒後に元に戻ってコンソールに次のように出ます。

done: 42

よくあるエラーと対処法

  • 「window.set_cursor_icon not allowed. Permissions associated with this command: core:window:allow-set-cursor-icon」: 権限の追加漏れです。リリースビルドでは「Command plugin:window|set_cursor_icon not allowed by ACL」と出ます。setCursorVisible() なども同じ形で、コマンド名と権限名だけが変わります。
  • 「Argument of type '"pointer"' is not assignable to parameter of type 'CursorIcon'.」: CSS の名前を setCursorIcon() に渡しています。上の対応表のとおり 'hand' などに直します。
  • ボタンや入力欄の上だけ待機カーソルにならない: body にだけ指定しています。html.busy * と !important で子要素まで上書きします。
  • setCursorIcon() の指定がすぐ元に戻る: ページ側の CSS が優先されています。アイコンは CSS で変えます。

OS ごとの違いと注意点

  • Windows: setCursorVisible(false) で隠れるのはウィンドウの中だけです。
  • macOS: setCursorVisible(false) は、ウィンドウにフォーカスがある間はウィンドウの外でもカーソルを隠します。ほかのアプリを操作しようとしても見えないので、ページから出たとき(document.documentElement の mouseleave)に表示へ戻すか、CSS の cursor: none を使います。
  • setCursorGrab(): Linux は非対応です。macOS ではカーソルがその場に固定され、見た目が不自然になります。
  • iOS / Android: ウィンドウ単位のカーソルの API はデスクトップ専用です。
  • 共通: wait や progress の絵柄は OS とユーザーのカーソル設定に従います。どの OS でも同じ見た目にしたいなら、CSS で独自の画像を指定します。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する