When I only started thinking about Lotus, I knew I'd use Electron. Developers like to joke that you need 64 gigs of RAM to run any Electron app, but I find this next joke much funnier:
My app uses 0 GB of RAM, because I didn't finish it
I couldn't find the original tweet, but you get the idea. If I had decided to create a native app using Swift, I bet I'd spend several months failing all the time because I have almost no experience with that programming language and then just give up.
So I chose Electron and used my existing skills of building web apps to create an app for Mac. I'm glad I did, because I had a working prototype in just 4 days.
However, just because Lotus isn't a native app, it doesn't mean it can't feel like a native app.
This week's newsletter is about that, making Electron apps embrace the same standards and patterns of native apps. I've written down everything I know about it so far, hope it will be useful to my fellow Electron developers!
Electron is essentially a web browser underneath, so it needs to load all the HTML, CSS and JavaScript files of your app after window is created. This can take some time, that's why Electron windows show up blank for a fraction of second.
There's a small trick to show a window only after page is loaded:
const {BrowserWindow} = require('electron');
const window = new BrowserWindow({
show: false
});
window.once('ready-to-show', () => {
window.show();
});
Check out how it looks after applying this change and compare it to the demo above:
When you move a window somewhere or resize it, Lotus remembers the new position and dimensions of that window. Next time you launch Lotus, window will be in the exact same position it was the last time and have the same width and height. It's one of those things that are hard to notice, but users have still learned to expect this from native apps.
Thanks to electron-window-state it's quite easy to implement for any Electron app.
In macOS apps often have a custom titlebar and users expect to be able to drag the entire window by pressing on the empty space there.
Here's a demo of how you can drag the window by pressing anywhere in the top area of the app:
Note how window is not moving when I'm trying to drag by pressing on the "Inbox" label. This is an important detail to keep in mind.
To implement these draggable areas I use two CSS classes:
.drag {
-webkit-app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
}
You can add a .drag
class to the entire titlebar container element and selectively add .no-drag
to elements that should prevent the drag interaction. Here's an example:
<div class="drag">
<h1 class="no-drag">Inbox</h1>
</div>
I have to admit that I made it for 5 months before realizing that text in Lotus looks bigger compared to all the other apps I use. Styling in Lotus is powered by Tailwind and it sets a default font size of 16px. This looks fine on the web, but it certainly stands out inside a desktop app.
Sindre told me that a default system font size in native apps is 13px, but it didn't look good in Lotus, so I went with 14px as a compromise. Actually, I like it more now!
Tailwind uses rem
unit to define all sizes in its source code, which allowed me to fix the font size issue by adding one line of code.
html {
font-size: 14px;
}
In CSS, rem
is calculated relatively to the root font size. So in this case, if I'd specify 1rem
, browser would interpret it as 14px
, because that's what I've set above for the entire page.
Also, use system font in your Electron app to make it a good macOS citizen. Tailwind sets it for me by default, but here's how to use a system font if you're not a Tailwind user:
html {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
}
I literally discovered this a few days ago when Sindre pointed it out to me. Native apps use a default cursor (not the "hand" one) even for buttons and other clickable elements. I completely blocked that out, because I'm so used to setting cursor: pointer
for interactive elements on the web.
This is simple to fix too:
*, a, button {
cursor: default;
user-select: none;
}
Pointer (or "hand") cursor should only be used for actual links that lead outside the app.
This feature needs no introduction, but there's one little-known trick to support dark mode flawlessly in Electron. Let me describe the problem first though.
Lotus has a dark gray background in dark mode and one day when I was resizing its window, I noticed this:
Default background color in Electron window is white. When I'm quickly resizing it, Electron can't resize the page inside as fast as native apps do, which results in these flashes of white background, even though my page has a gray background.
To fix this, set window background color to the same color that's used on the page. Then, update it whenever system switches to/from dark mode.
const {nativeTheme, BrowserWindow} = require('electron');
const darkBackgroundColor = 'black';
const lightBackgroundColor = 'white';
const window = new BrowserWindow({
backgroundColor: nativeTheme.shouldUseDarkColors
? darkBackgroundColor
: lightBackgroundColor
});
nativeTheme.on('updated', () => {
const backgroundColor = nativeTheme.shouldUseDarkColors
? darkBackgroundColor
: lightBackgroundColor;
window.setBackgroundColor(backgroundColor);
});
You won't see any flashes of white background anymore no matter how fast you resize the window.
Lotus has a sidebar navigation with colorful icons inside each item and a bright purple background for a currently selected page. When Lotus is focused, all colors are displayed as-is:
But if you click away or switch to some other app, Lotus loses focus and replaces colors with the shades of gray:
This seems like another small pattern from native apps that is easy to miss. It also requires code in both main and renderer processes to make it work.
In the main process, you need to detect when window is focused or unfocused and pass these events to the renderer process. Because renderer process is basically a browser, the page never loses focus in its "eyes", since it's always visible within Electron window.
window.on('focus', () => {
window.webContents.send('focus');
});
window.on('blur', () => {
window.webContents.send('blur');
});
Then, in renderer process you need to listen to these messages from the main process by using ipcRenderer
module.
const {ipcRenderer} = require('electron');
ipcRenderer.on('focus', () => {
// Change UI state to focused
});
ipcRenderer.on('blur', () => {
// Change UI state to unfocused
});
Lotus is written in React, so I packaged the renderer piece into a handy useWindowFocus
hook, which I use like this:
const isWindowFocused = useWindowFocus();
return <NavItem className={isWindowFocused ? 'bg-purple' : 'bg-gray'}>…</NavItem>;
Most Mac apps have a standard menu and Electron apps should have it too.
It's fairly simple to set it up by using Menu
class provided by Electron. Here are some useful links to get you started quicker and create a standard macOS menu immediately:
I opted to create a custom menu in Lotus, because I needed a lot of custom items in there. Which also brings us to the next tip.
It's still somewhat rare to spot proper keyboard shortcuts in web apps, but they're a first-class citizen in native ones. It's really simple to add them in Electron, so you literally don't have any excuse not to! First, add a custom menu item, then use an accelerator
property to configure a shortcut that's going to trigger that item.
{
label: 'Refresh',
accelerator: 'CmdOrCtrl+R',
click: () => {
// User clicked on the menu item or pressed ⌘R
}
}
It may sound weird at first that a menu item is required for a shortcut to work, but keep in mind that users often browse the app's menu first and only then learn which shortcuts it has.
In Lotus I created a separate menu section for actions related to managing a notification that's currently displayed with a shortcut assigned to each action:
This is another feature that web apps often miss. It's interesting that we always expect native apps to allow us to undo or redo any action, but we don't have the same expectation on the web. Anyway, make sure to add this to your Electron app sooner or later, it's going to significantly up your native-app game.
Undo / redo was a complicated feature to develop and I had to rewrite it multiple times, but I think I've landed at an implementation that is abstract enough to be reused and open-sourced later.
I made the mistake of showing "Preferences" page just like all other pages in the sidebar navigation before, but now Lotus has a separate native-like window. It even animates as you switch between tabs! Time well spent for sure.
There's also no need to add any button in the UI to open preferences window, because all native macOS apps follow the same pattern of adding "Preferences" item to the menu and using a <kbd>⌘,</kbd> shortcut to open it.
Bad news here, there's nothing I could find to create preferences window quickly, so you'll need to code it yourself.
Unless your app absolutely can't function without an internet connection, it should gracefully degrade to an offline-first experience by syncing changes when a connection becomes available. I actually almost finished implementing offline support in Lotus, even though it depends on external data from GitHub API.
Here's a few tips on how Lotus works offline:
Hope this deep dive into Electron UX was interesting and useful for you!
What other details or patterns would make Electron apps feel more native? Anything I missed? Feel free to let me know by replying to this email.
You can also just drop by to say "hi", that works too! Your message goes directly into my inbox and I try to reply to everyone as soon as possible to keep the conversation going :)
Have a great week everyone, see you next time.
– Vadim.
I'm building Lotus in the open and I'm sending out progress updates just like this one every Sunday.
I won't send spam and you can unsubscribe anytime.