Skip to main content

ELL Blog

Tauri Custom Titlebar (React)

This tutorial is based on Part 12 of my Tauri & ReactJS series.

In this article, I’ll show you how I implemented custom titlebars for my Tauri apps. I’ll start off with an overview for what’s required.

The source code can be found here

  • Tauri APIs
  • Coding Titlebar.jsx
  • How to get the translations for the window control tooltips
  • Enabling/disabling the native & custom titlebar based on the Operating System and if in fullscreen
  • Using simplebar so that scrollbars are not beside the titlebar
  • Using window-shadows crate to ensure rounded corners on Windows 11

Table of Contents

package.json

The react libraries used are @mantine/hooks, react-i18next, simplebar-react, react-icons.

I also use a styling library, @mantine/core, but it’s not a hard requirement for a custom titlebar.

Tauri APIs

src-tauri/tauri.conf.json: Enable window APIs
{
    // ...
    "tauri": {
        // ...
        "allowlist": {
            // ...
            "window": {
                    // ...
                    "close": true,
                    "maximize": true,
                    "minimize": true,
                    "setDecorations": true,
                    "startDragging": true,
                    "unmaximize": true,
                    "unminimize": true
                }
        }
    }
}
src-tauri/tauri.windows.conf.json: disable decorations by default on Windows
{
    "tauri": {
        "windows": [
            {
                // copy window details from tauri.conf.json
                "decorations": false,
              }
        ]
    }
}

Titlebar.jsx

NOTE: This is a Windows replica. This article does not support custom Mac titlebars since I don’t own a Mac and thus cannot replicate it so easily.

Create a src/Component/Titlebar.jsx where we will create a component that works in a plug and play manner.

You can modify this component to either use a different UI/styling library as well as to change the default title. In a future version of Tauri, the Titlebar will automatically synchronize the title (if you need synching asap for a production app, use a tauri version from a git revision rather than creates.io).

There is some code commented out if you want the title to be in the center rather than the left side. The styling was hand picked, especially the reds used for the close button. If you want to, you can remove or replace the Menu that shows up when you hover the app icon.

Code
import { createStyles, Menu, Text, UnstyledButton } from '@mantine/core';
import { useInterval } from '@mantine/hooks';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VscChromeClose, VscChromeMaximize, VscChromeMinimize, VscChromeRestore } from 'react-icons/vsc';
import AppIcon from '../../src-tauri/icons/32x32.png';

export function Titlebar() {
    const { t } = useTranslation();
    const { classes } = getTitleBarStyles();
    const [maximized, setMaximized] = useState(false);
    const [fullscreen, setFullscreen] = useState(false);
    const [windowTitle, setWindowTitle] = useState('TitleBar.jsx Title');

    const tauriInterval = useInterval(() => {
        appWindow.isMaximized().then(setMaximized);
        appWindow.isFullscreen().then(setFullscreen);
        appWindow.title().then(setWindowTitle);
    }, 200);

    useEffect(() => {
        tauriInterval.start();
        return tauriInterval.stop;
    }, []);

    return !fullscreen && <div data-tauri-drag-region className={classes.titlebar}>
        <div>
            {/* window icon */}
            <Menu shadow='md' width={200}>
                <Menu.Target>
                    <UnstyledButton style={{ cursor: 'default' }}><img className={classes.titlebarIcon} height={16} src={AppIcon} /></UnstyledButton>
                </Menu.Target>
                <Menu.Dropdown>
                    <Menu.Item onClick={() => appWindow.minimize()} icon={<VscChromeMinimize size={14} />}>{t('Minimize')}</Menu.Item>
                    {maximized ?
                        <Menu.Item onClick={() => appWindow.toggleMaximize()} icon={<VscChromeRestore size={14} />}>{t('Restore Down')}</Menu.Item> :
                        <Menu.Item onClick={() => appWindow.toggleMaximize()} icon={<VscChromeMaximize size={14} />}>{t('Maximize')}</Menu.Item>}
                    <Menu.Divider />
                    <Menu.Item onClick={() => appWindow.close()} icon={<VscChromeClose size={14} />} rightSection={
                        <Text weight='bold' size='xs'>Alt + F4</Text>}>{t('Close')}</Menu.Item>
                </Menu.Dropdown>
            </Menu>
            {/* left window title */}
            <Text data-tauri-drag-region inline className={classes.titlebarLabel} size='xs'>{windowTitle}</Text>
        </div>
        {/* center window title */}
        {/* <Text data-tauri-drag-region inline className={classes.titlebarLabel} size='xs'>{windowTitle}</Text> */}
        <div>
            {/* window icons */}
            <div title={t('Minimize')} className={classes.titlebarButton} onClick={() => appWindow.minimize()}>
                <VscChromeMinimize className={classes.verticalAlign} />
            </div>
            {maximized ?
                <div title={t('Restore Down')} className={classes.titlebarButton} onClick={() => appWindow.toggleMaximize()}>
                    <VscChromeRestore className={classes.verticalAlign} />
                </div> :
                <div title={t('Maximize')} className={classes.titlebarButton} onClick={() => appWindow.toggleMaximize()}>
                    <VscChromeMaximize className={classes.verticalAlign} />
                </div>
            }
            <div title={t('Close')} className={`${classes.titlebarClose} ${classes.titlebarButton}`} onClick={() => appWindow.close()}>
                <VscChromeClose className={classes.verticalAlign} />
            </div>
        </div>
    </div>;
}

const getTitleBarStyles = createStyles(theme => ({
    titlebarIcon: {
        marginLeft: 5,
        verticalAlign: 'bottom',
        filter: theme.colorScheme === 'dark' ? '' : 'grayscale(100%) contrast(0)'
    },
    verticalAlign: {
        verticalAlign: 'middle'
    },
    titlebarLabel: {
        display: 'inline',
        marginLeft: 5,
        // marginLeft: 46 * 3 - 16 - 7.5  // for center labels
        lineHeight: '30px'
    },
    titlebar: {
        height: 30,
        background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[1],
        // background: theme.colorScheme === 'dark' ? theme.colors.dark[7] : 'white',
        display: 'flex',
        justifyContent: 'space-between',
        position: 'fixed',
        userSelect: 'none',
        top: 0,
        left: 0,
        right: 0,
        zIndex: 1000,
        '>div:nth-of-type(2)': {
            display: 'flex',
            justifyContent: 'flex-end',
        }
    },
    titlebarButton: {
        transitionDuration: '200ms',
        display: 'inline-flex',
        justifyContent: 'center',
        alignItems: 'center',
        '>svg': {
            fill: theme.colorScheme === 'dark' ? 'white' : 'black',
        },
        width: 46,
        height: 30,
        '&:hover': {
            background: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[3],
            '&:active': {
                background: theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4],
            }
        }
    },
    titlebarClose: {
        '&:hover': {
            background: '#e81123',
            '>svg': {
                fill: 'white'
            },
            '&:active': {
                background: theme.colorScheme === 'dark' ? '#8b0a14' : '#f1707a',
            }
        }
    }
}));

i18n.js

To get the translations for the buttons in another language, do not use Google translate. Rather, change the display language for Windows and after logging back in hover the window control buttons of native Windows applications. I did this for French (Canadian) and got the following. Google Translate gave something different for Maximize and Restore down.

fr: {
    translations: {
      'Minimize': 'Réduire',
      'Maximize': 'Agrandir',
      'Restore Down': 'Niveau inf.',
      'Close': 'Fermer',
    }
}

How to use Titlebar.jsx effectively

To use the titlebar effectively, we need to first determine at runtime if we are using a custom titlebar or not. We also need to use scrollbars for inner components and disable the scrollbar for the entire window. Otherwise the scrollbar will show up beside the custom titlebar.

In src/App.jsx I have the following code. src/TauriProvider.jsx implementation

import { useState, useEffect, useRef } from 'react';
import SimpleBar from 'simplebar-react';
import 'simplebar/dist/simplebar.min.css';
import { Titlebar } from './Components/Titlebar';
import { useInterval } from '@mantine/hooks';
import { appWindow } from '@tauri-apps/api/window'
import { useTauriContext } from './TauriProvider';
import { WIN32_CUSTOM_TITLEBAR } from './utils';  // this is a constant set to true

// ...
   // use the custom title bar only on Windows
  const { osType } = useTauriContext();
  useEffect(() => {
    if (osType === 'Windows_NT') appWindow.setDecorations(!WIN32_CUSTOM_TITLEBAR);
  }, [osType]);

  // hide titlebar in fullscreen
  const [fullscreen, setFullscreen] = useState(false);
  const tauriInterval = useInterval(() => {
    appWindow.isFullscreen().then(setFullscreen);
  }, 200);
  useEffect(() => {
    tauriInterval.start();
    return tauriInterval.stop
  }, []);

  // use this variable like so <COMPONENT className={using_custom_titlebar ? classes.titlebarMargin : ''} />
  const using_custom_titlebar = !fullscreen && osType === 'Windows_NT' && WIN32_CUSTOM_TITLEBAR;
  // ...
  const scrollbar = useRef(); // pass this into a Scroll to top component
  return <>
        {using_custom_titlebar && <Titlebar />}
        {/* if you are using mantine, set dynamic global styles for  the custom scrollbar*/}
        {/* <Global styles={titlebarOverrides} /> */}
        <SimpleBar scrollableNodeProps={{ref: scrollbar}} autoHide={false} className={classes.simpleBar}>
            {/* code goes here */}
        </SimpleBar>
    </>;

Classes are as follows in my case

    simpleBar: {
        maxHeight: '100vh',
    },
    titlebarMargin: {
        marginTop: '2em'
    },
    headerOverrides: {
        maxHeight: 'calc(70px + 1em)',
        paddingBottom: '0 !important',
        marginTop: '1em',
    },

Global styles:

    '.simplebar-vertical': {
        backgroundClip: 'padding-box',
        marginTop: using_custom_titlebar ? 100 : 70,
        marginBottom: showFooter ? 50 : 0,
    },
    body: {
        overflowY: 'hidden'
    }

Windows 11 Rounded Corners

To add back the rounded corners when decorations are off, add window-shadows to your Cargo.toml

[dependencies]
window-shadows = {git = "https://github.com/tauri-apps/window-shadows", branch = "dev" }

Usage in main.rs:

// -----------------------------
use window_shadows::set_shadow;
// -----------------------------

fn main() {
    tauri::Builder::default()
// -----------------------------
        .setup(|app| {
            if let Some(window) = app.get_window("main") {
                set_shadow(&window, true).expect("Unsupported platform!");
            }
            Ok(())
        })
// -----------------------------
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Conclusion

This took around a couple hours to implement and I’ve provided this code to give you a headstart as I know very well everyone’s implementation will be slightly different.