Custom title bar for Electron app (Windows and MAC)

Creating a custom title bar for your electron app can be a bit hectic, especially handling edge cases. Recently I worked on an electron app where I had to do some research to build a custom title bar. I was inspired by Ronnie Dutta's article. I noticed that remote module has been deprecated in Electron so I have implemented functionality with IPC and added MAC title bar.

So here is a guide for making a custom title bar for your app which is compatible with Windows as well as MAC.

Initial Styling

Firstly we add some basic styling to the quick start app. Open/create the empty style.css and add the following:

* {margin: 0; padding: 0; border: 0; vertical-align: baseline;}
html {box-sizing: border-box;}
*, *:before, *:after {box-sizing: inherit;}
html, body {height: 100%; margin: 0;}

body {
  font-family: "Segoe UI", sans-serif;
  background: #1A2933;
  color: #FFF;
}
h1 {margin: 0 0 10px 0; font-weight: 600; line-height: 1.2;}
p {margin-top: 10px; color: rgba(255,255,255,0.4);}

Make the window frameless

Let's remove the default title bar and border. In main.js, modify the new BrowserWindow() line so it includes frame: false:

mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    frame: false,
    backgroundColor: '#FFF',
    webPreferences: {
        nodeIntegration: true
    }
});

nodeIntegration is enabled for the window controls JavaScript to work

Uncomment mainWindow.webContents.openDevTools() to open developer tools every time the app is run.

If we run our app now, we will notice that the default title bar has disappeared.

Create a replacement title bar

We're going to create our custom title bar using HTML and CSS (which can also be done in React). Let's also put the rest of the app content in its div:

<body>
  <header id="titlebar"></header>
  <div id="main">
    <h1>Hello World!</h1>
    <p>Lorem ipsum dolor sit amet...</p>
  </div>
</body>

The default title bar height in Windows is 32px. We want the title bar fixed at the top of the DOM. Added 1px border to the window. We need to add a 32px top margin to #main, and change the overflow-y for #main and body (#main is now replacing body as the scrolling content).

body {
  border: 1px solid #48545c;
  overflow-y: hidden;
}

#titlebar {
  display: block;
  position: fixed;
  height: 32px;
  width: calc(100% - 2px); /*Compensate for body 1px border*/
  background: #254053;
}

#main {
  height: calc(100% - 32px);
  margin-top: 32px;
  padding: 20px;
  overflow-y: auto;
}

Now to make our title bar draggable we add a div which serves as our draggable region

<header id="titlebar">
  <div id="drag-region"></div>
</header>

and now we add the style of -webkit-app-region: drag to make it draggable.

The reason we don't just add this style to #titlebar is that we also want the cursor to change to resize when we hover near the edge of the window at the top. If the whole title bar was draggable, this wouldn't happen. So we also add some padding to the non-draggable #titlebar element.

#titlebar {
  padding: 4px;
}

#titlebar #drag-region {
  width: 100%;
  height: 100%;
  -webkit-app-region: drag;
}

Adding and Styling window control buttons

Now let's add minimize, maximize, restore and close buttons. To do this, we'll need the icons. You can get icon assets here :

github.com/binaryfunt/electron-seamless-tit..

We'll put the buttons inside the #drag-region div

Added window-hover so that we can have a hover effect over the buttons. We will later replace it with mac-hover for hover over mac controls.

 <header id="titlebar">
    <div id="drag-region">
      <div id="window-title">
        <img id="logo" src="assets/icons/logo.svg" alt="my logo">
      </div>
      <div id="window-controls">
        <div class="button window-hover" id="min-button">
          <img class="icon" id="min" alt="minimize"
            srcset="assets/icons/min-w-10.png"
            draggable="false" />
        </div>
        <div class="button window-hover" id="max-button">
          <img class="icon" id="max" alt="maximize"
            srcset="assets/icons/max-w-10.png"
            draggable="false" />
        </div>
        <div class="button window-hover" id="restore-button">
          <img class="icon" id="restore" alt="restore"
            srcset="assets/icons/restore-w-10.png"
            draggable="false" />
        </div>
        <div class="button window-hover-close" id="close-button">
          <img class="icon" id="close" alt="close"
            srcset="assets/icons/close-w-10.png"
            draggable="false" />
        </div>
      </div>
    </div>
  </header>

We'll use CSS grid to overlap the maximize/restore buttons and later use JavaScript to alternate between them.

#titlebar {
  color: #FFF;
}

#window-controls {
  display: grid;
  grid-template-columns: repeat(3, 46px);
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
}

#window-controls .button {
  grid-row: 1 / span 1;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}
#min-button {
  grid-column: 1;
}
#max-button, #restore-button {
  grid-column: 2;
}
#close-button {
  grid-column: 3;
}

First of all, the buttons shouldn't be part of the window drag region, so we'll exclude them. Also, we don't want to be able to select the icon images. We also need to add hover effects. The default Windows close button hover color is #E81123. When active, the button becomes #F1707A and the icon becomes black, which can be achieved using the invert filter. Lastly, we'll hide the restore button by default (again, we'll implement switching between the maximize/restore buttons later).

We have written separate CSS for mac and windows title bar.

For mac icon, we create the icons with their div with circular radius and background and put images inside it with display: none, the image of the icon will appear on hover over mac-controls.

mac-hover is a class over the control icon's image. mac-controls is a wrapper around mac control icons so that when we hover over icons area the images of icon appear like it happens in mac .

#window-controls {
  -webkit-app-region: no-drag;
}

#window-controls .button {
  user-select: none;
}

.window-hover:hover {
    background: rgba(255,255,255,0.1);
  }
  .window-hover:hover:active {
    background: rgba(255,255,255,0.2);
  }

  .window-hover-close:hover {
    background: #E81123 !important;
  }
  .window-hover-close:active {
    background: #F1707A !important;
  }
  .window-hover-close:active .icon {
    filter: invert(1);
  }

.mac-controls:hover .mac-hover{
    display: flex;
}
.mac-hover{
display: none;
}
#restore-button {
  display: none !important;
}

Now the windows title bar should look like this :

Now let's implement the title bar for mac. We can do it in two ways , either make another toolbar from scratch or just change css and icons according to our need because we will add same functionality on the controls icons of mac as well as windows. So i'll choose the latter and just change css of windows title bar when we detect process.platform === "darwin".

So in our render.js file, we can check if the platform is mac, and change the style using js. We will do it like this :


if(window.process && process.platform === "darwin"){
    let titleBar = document.getElementById("titlebar");
    titleBar.style.backgroundColor="ghostwhite";

    let windowControl = document.getElementById("window-controls");
    windowControl.classList.add('mac-controls')

    document.querySelectorAll('.button').forEach(el => {
        el.style.height='16px'
        el.style.width='16px'
        el.classList.add('mac-button')
        el.classList.remove('window-hover')
        el.classList.remove('window-hover-close')
    });

    document.querySelectorAll('.icon').forEach(el => {
        el.classList.add('mac-hover')
        el.style.margin='auto'
    });

    windowControl.style.alignItems='center'
    windowControl.style.right = "unset";
    windowControl.style.gridTemplateColumns = "repeat(3, 24px)"
    let windowTitle = document.getElementById("window-title");
    windowTitle.style.justifyContent = "center";
    document.getElementById("logo").src = "assets/icons/logo-black.svg";

    let minimizeImg = document.getElementById("min");
    minimizeImg.style.height="8px";
    minimizeImg.style.width="8px";
    minimizeImg.srcset = "assets/icons/mac/minimize-icon-mac.svg";
    let minBtn=document.getElementById('min-button')
    minBtn.style.gridColumn=2
    minBtn.style.backgroundColor="#FFB32C";
    minBtn.style.borderRadius="3rem";

    let restoreImg = document.getElementById("restore");
    restoreImg.style.height="8px";
    restoreImg.style.width="8px";
    restoreImg.srcset = "assets/icons/mac/restore-icon-mac.svg";
    let restoreBtn=document.getElementById('restore-button')
    restoreBtn.style.gridColumn=3
    restoreBtn.style.backgroundColor="#40C057"
    restoreBtn.style.borderRadius="3rem"

    let maximizeImg = document.getElementById("max");
    maximizeImg.style.height="8px";
    maximizeImg.style.width="8px";
    maximizeImg.srcset = "assets/icons/mac/maximize-icon-mac.svg";
    let maxBtn=document.getElementById('max-button')
    maxBtn.style.gridColumn=3 
    maxBtn.style.backgroundColor="#40C057"
    maxBtn.style.borderRadius="3rem"

    let closeImg = document.getElementById("close");
    closeImg.style.height="6px";
    closeImg.style.width="6px";
    closeImg.srcset = "assets/icons/mac/close-icon-mac.svg";
    let closeBtn=document.getElementById('close-button')
    closeBtn.style.gridColumn=1
    closeBtn.style.backgroundColor="#FA5252"
    closeBtn.style.borderRadius="3rem"
}

Now the mac title bar should look like this :

Implementing window controls functionality (using IPC)

Now how do we implement functionality, so that the window gets maximized on clicking maximize button and the icon changes when the user changes the state of window by dragging and minimizing it or Win + Arrow .

This two-way communication can be achieved with IPC in electrons:

ipcRenderer and ipcMain can be required from electron.

Let's write renderer-side logic first in render.js. We require ipcRenderer conditionally if window.process is not present (i.e its browser environment) we hide the title bar (handled for the browser).

if (window.process) {
    var { ipcRenderer } = window.require("electron");
}
else {
    document.getElementById('titlebar').style.display='none'
}

We initialize our window controls function once the document is loaded

document.onreadystatechange = (event) => {
    if (document.readyState == "complete") {
        handleWindowControls();
    }
};

Then we write logic to handle button actions, we have common css id for buttons in mac and windows, so we add event listeners for button click.

We use ipcRenderer.send to send message to main.js with the first argument being event name and the second message.

Here we are sending MAXIMIZE_WINDOW message for maximize as well as unmaximize for convenience. Because maximize and unmaximize buttons replace each other.

function handleWindowControls() {
// Make minimise/maximise/restore/close buttons work when they are clicked
document.getElementById('min-button').addEventListener("click", event =>{
    ipcRenderer.send('TITLE_BAR_ACTION',"MINIMIZE_WINDOW")
    });

document.getElementById('max-button').addEventListener("click", event =>{
    ipcRenderer.send('TITLE_BAR_ACTION',"MAXIMIZE_WINDOW")
    });

document.getElementById('restore-button').addEventListener("click", event                => {
    ipcRenderer.send('TITLE_BAR_ACTION',"MAXIMIZE_WINDOW")
    });

document.getElementById('close-button').addEventListener("click", event => {
    ipcRenderer.send('TITLE_BAR_ACTION',"CLOSE_APP")
    });
}

Now that we can send an IPC call from renderer.js to main.js. Let's handle the IPC request in main.js.

We import ipcMain in main.js and use it to listen to any request sent from the renderer. We already have the object of the window which we created using new BrowserWindow at the start. If we have multiple windows using the title bar, then their respective window object can be passed in the first argument of handleTitleBarActions

    ipcMain.on('TITLE_BAR_ACTION', (event, args) => {
        handleTitleBarActions(mainWindow, args)
    });

Now the logic of handleTitleBarActions is as follows. We handle windowObj.isMaximized() that if the window is already maximized it will unmaximize the window.


function handleTitleBarActions(windowObj, args) {
    if (args === 'MAXIMIZE_WINDOW') {
        if (windowObj.isMaximized() === true) {
            windowObj.unmaximize();
        }
        else {
            windowObj.maximize()
        }
    }
    else if (args === 'MINIMIZE_WINDOW')
        windowObj.minimize()
    else if (args === 'CLOSE_APP')
        windowObj.quit()
}

But still, we have a few edge case, where if we maximize or unmaximize the window using Win + Arrow key or use drag to do the same. The icon of maximize does not change. This is because render is not informed when the window gets changed (maximize or unmaximize). Also if we refresh the icon gets reset to maximize button.

So to solve this we have to send a request from main.js to render.js. For this, we use win.webContents.send to tell render that some event has occurred in main.js. We detect the maximize and unmaximize event on window obj by :

    mainWindow.on('unmaximize',()=>{
        mainWindow.webContents.send("unmaximized")
    })

    mainWindow.on('maximize',()=>{
        mainWindow.webContents.send("maximized")
    })

This will keep the window updated. On the renderer side listen to these events and change the icon accordingly. To avoid any browser error I have wrapped it in the if window.process exists.

if(window.process){
ipcRenderer.on('unmaximized',()=>{
    document.getElementById('restore-button').style.display='none'
    document.getElementById('max-button').style.display='flex'
})
ipcRenderer.on('maximized',()=>{
    document.getElementById('restore-button').style.display='flex'
    document.getElementById('max-button').style.display='none'
})
}

Also now let's handle the edge case where when we refresh the page the maximize and restore icons get reset and max-button is visible even when window is in unmaximize state. For that, we do as follows on the main.js side

    win.webContents.on('did-stop-loading', (e) => {
        if(win.isMaximized()===true){
            win.webContents.send("maximized")
        }
        else{
            win.webContents.send("unmaximized")
        }
      })

Now we see that our title bar works properly on both mac as well as windows, and the icons change states properly.

This was my implementation of the title bar which is compatible with mac as well as windows . Hope you guys liked it ,

Do subscribe to the newsletter for weekly react and javascript-related content.