枠のないフレームレスウィンドウを作る

decorations: false で標準のタイトルバーと枠を消し、data-tauri-drag-region と自作ボタンで移動・最小化・閉じるを作り直す。必要な権限と setDecorations() での切り替えも示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約10分 win-009
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 自作のタイトルバーを置く
  4. 右下にリサイズ用のつまみを置く
  5. 実行中に枠を付け外しする
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

OS 標準のタイトルバーと枠を消した「フレームレス」ウィンドウにすると、タイトルバーまで HTML/CSS で自由にデザインできます。設定ファイルの decorations: false で作り、実行中は setDecorations() で切り替えます。ただし消えるのは見た目だけではありません。タイトルバーでの移動や、最小化・最大化・閉じるのボタンも無くなるので、それらを作り直すところまでがセットです。背景の透明化は 透明なウィンドウ、フレームレス時の影は 影を付ける・消す で扱います。

前提条件

プラグインは不要です。decorations: false 自体に権限は要りませんが、作り直す操作ごとに権限が必要で、多くは core:default に含まれません。

操作API権限core:default
ドラッグで移動data-tauri-drag-region / startDragging()core:window:allow-start-dragging含まれない
ダブルクリックで最大化data-tauri-drag-regioncore:window:allow-internal-toggle-maximize含まれる
最小化minimize()core:window:allow-minimize含まれない
最大化・元に戻すtoggleMaximize()core:window:allow-toggle-maximize含まれない
閉じるclose()core:window:allow-close含まれない
つまみでリサイズstartResizeDragging()core:window:allow-start-resize-dragging含まれない
枠の付け外しsetDecorations()core:window:allow-set-decorations含まれない

isDecorated() と isMaximized() の読み取りは 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-start-dragging",
    "core:window:allow-minimize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-close",
    "core:window:allow-start-resize-dragging",
    "core:window:allow-set-decorations"
  ]
}

title は画面に描かれなくなりますが、タスクバーなどに出る名前として使われるので残しておきます。

{
  "app": {
    "windows": [
      { "label": "main", "title": "My App", "width": 960, "height": 640, "decorations": false }
    ]
  }
}

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

自作のタイトルバーを置く

<header class="titlebar" data-tauri-drag-region>
  <span class="titlebar-title" data-tauri-drag-region>My App</span>
  <div class="titlebar-buttons">
    <button id="tb-min" type="button" aria-label="最小化">&#x2212;</button>
    <button id="tb-max" type="button" aria-label="最大化">&#x25A1;</button>
    <button id="tb-close" type="button" aria-label="閉じる">&#x2715;</button>
  </div>
</header>
<main class="content">本文</main>
.titlebar {
  position: fixed;
  inset: 0 0 auto 0;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-left: 12px;
  background: #20232a;
  color: #fff;
  user-select: none;
}
.titlebar-buttons button {
  width: 46px;
  height: 36px;
  border: 0;
  background: transparent;
  color: inherit;
}
.titlebar-buttons button:hover { background: rgba(255, 255, 255, 0.12); }
#tb-close:hover { background: #e81123; }
.content { margin-top: 36px; }
body:not(.frameless) .titlebar { display: none; }
body:not(.frameless) .content { margin-top: 0; }

data-tauri-drag-region を付けた要素を押すとドラッグで移動でき、ダブルクリックで最大化と元に戻すが切り替わります。注意点は、この属性が付けた要素そのものにしか効かないことです。中の <span> を押しても動かないので、子要素にも属性を付けます。2.11 系では、親に data-tauri-drag-region="deep" と書けば子孫全体がドラッグ領域になります(効かない版では子に個別に付けます)。<button>、<a>、<input> などは、属性の付いた親の中にあっても自動でドラッグ対象から外れるので、普通にクリックできます。

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

const win = getCurrentWindow();

function onClick(id: string, action: () => Promise<void>) {
  document.getElementById(id)?.addEventListener('click', () => {
    action().catch((e) => console.error(e)); // 権限不足のエラーはここに出る
  });
}

onClick('tb-min', () => win.minimize());       // core:window:allow-minimize
onClick('tb-max', () => win.toggleMaximize()); // core:window:allow-toggle-maximize
onClick('tb-close', () => win.close());        // core:window:allow-close

// ダブルクリックでも最大化が変わるので、リサイズのたびにボタンを合わせる
async function syncMaxButton() {
  const btn = document.getElementById('tb-max');
  if (!btn) return;
  const maximized = await win.isMaximized();
  btn.textContent = maximized ? '❐' : '□';
  btn.setAttribute('aria-label', maximized ? '元に戻す' : '最大化');
}

document.body.classList.toggle('frameless', !(await win.isDecorated()));
void win.onResized(() => void syncMaxButton());
void syncMaxButton();

close() は標準の × ボタンと同じく閉じる前のイベントを発生させるので、閉じる前の確認 もそのまま使えます。メニューまで自作バーに入れるなら 自作のタイトルバーにメニューを組み込む、ドラッグ領域の細かい指定は ドラッグ移動できる領域を作る を参照してください。

右下にリサイズ用のつまみを置く

枠が無いと、どこをつかめばリサイズできるのか利用者から見えません。つまみを置き、押されたら startResizeDragging() を呼ぶと、そこからリサイズできます。

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

const grip = document.createElement('div');
grip.style.cssText = 'position:fixed;right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize;';
document.body.append(grip);

grip.addEventListener('mousedown', (e) => {
  if (e.button !== 0) return;
  e.preventDefault();
  // 'SouthEast' = 右下方向。core:window:allow-start-resize-dragging が必要
  getCurrentWindow().startResizeDragging('SouthEast').catch((err) => console.error(err));
});

実行中に枠を付け外しする

「ミニ表示では枠を消す」のように切り替えるときは、標準の枠と自作バーを入れ替えます。上の CSS の .frameless クラスで自作バーの表示を切り替えています。

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

const win = getCurrentWindow();

export async function toggleFrame() {
  const decorated = await win.isDecorated();
  await win.setDecorations(!decorated); // core:window:allow-set-decorations
  document.body.classList.toggle('frameless', decorated); // 枠を消したら自作バーを出す
}

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

Rust では WebviewWindowBuilder の decorations(false) で作り、set_decorations() で切り替えます。下の例では、macOS だけ標準のボタン(信号機)を残すオーバーレイ型にしています(後述)。Windows ではウィンドウを同期コマンドで作ると固まることがあるので、作成するコマンドは async にします。

use tauri::{WebviewUrl, WebviewWindowBuilder};

/// フレームレスのサブウィンドウを開く
#[tauri::command]
async fn open_frameless(app: tauri::AppHandle) -> Result<(), String> {
    let builder = WebviewWindowBuilder::new(&app, "player", WebviewUrl::App("player.html".into()))
        .title("Player")
        .inner_size(480.0, 270.0);

    #[cfg(target_os = "macos")]
    let builder = builder
        .title_bar_style(tauri::TitleBarStyle::Overlay)
        .hidden_title(true);
    #[cfg(not(target_os = "macos"))]
    let builder = builder.decorations(false);

    builder.build().map_err(|e| e.to_string())?;
    Ok(())
}

/// 呼び出し元ウィンドウの枠を切り替え、切り替え後の状態を返す
#[tauri::command]
fn toggle_decorations(window: tauri::WebviewWindow) -> Result<bool, String> {
    let next = !window.is_decorated().map_err(|e| e.to_string())?;
    window.set_decorations(next).map_err(|e| e.to_string())?;
    Ok(next)
}

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

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

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

const decorated = await invoke<boolean>('toggle_decorations');
document.body.classList.toggle('frameless', !decorated);

動作確認

npm run tauri dev で起動すると、標準のタイトルバーが消えて自作のバーだけが表示されます。バーの余白やタイトル文字をドラッグすると移動し、ダブルクリックで最大化と元に戻すが切り替わり、ボタンの記号も変わります。右下のつまみでリサイズでき、toggleFrame() を呼ぶと標準の枠が戻って自作バーが隠れます。

よくあるエラーと対処法

  • ドラッグしても動かず、コンソールに「window.start_dragging not allowed. Permissions associated with this command: core:window:allow-start-dragging」: 権限の追加漏れです。リリースビルドでは「Command plugin:window|start_dragging not allowed by ACL」と出ます。
  • タイトル文字の上だけドラッグできない: 属性が親にしか付いていません。子要素にも付けるか "deep" を使います。
  • ボタンを押しても何も起きない: window.minimize not allowed. などで始まるエラーがコンソールに出ていれば、表の権限が足りません。
  • ダブルクリックしても最大化しない: resizable: false か maximizable: false のウィンドウでは、ドラッグ領域のダブルクリックは何もしません。

OS ごとの違いと注意点

  • 影: フレームレスにしたときの影の出方は OS で異なり、Windows では周りに細い白い線が付くことがあります。挙動と対処は 影を付ける・消す にまとめています。
  • macOS: decorations: false の代わりに、titleBarStyle: "Overlay" と hiddenTitle: true で標準のボタンを残したままタイトルバーの領域までページを広げる方法があります。この場合もドラッグ領域は自分で用意し、非アクティブなウィンドウはドラッグできない制限があります。タイトルバーの高さは macOS のバージョンで違うので、上端に余裕を持たせます。ボタンの位置は trafficLightPosition(2.4 以降、decorations: true と Overlay が条件)で調整できます。
  • macOS だけ設定を変える: src-tauri/tauri.macos.conf.json に書いた設定は macOS のときだけ tauri.conf.json に重ねられます。windows のような配列は丸ごと置き換わるので、ウィンドウの設定はすべて書き直します。
{
  "app": {
    "windows": [
      {
        "label": "main",
        "title": "My App",
        "width": 960,
        "height": 640,
        "titleBarStyle": "Overlay",
        "hiddenTitle": true
      }
    ]
  }
}

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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