Skip to content

Conversation

@martin-podlubny
Copy link

What?

This PR is an implementation of a macos-only feature I call "layered tray icons", where a tray icon can be composed of multiple images, (some template, some regular), and still behave sensibly across macos menubars of different colors.

const appleTemplate = nativeImage.createFromPath('/path/to/apple-Template.png');
const appleRedDot = nativeImage.createFromPath('/path/to/apple-red-dot.png');

const tray = new Tray({
  layers: [
    appleTemplate,
    appleRedDot
  ]
});

The main idea is composing icons with mixed rendering modes:

  • Template layers: Black+alpha images that automagically adapt to the menubar theme (light/dark).
  • Color layers: Fixed-color overlays that stay the same regardless of theme (like notification dots).

So you can have an apple icon that changes with the menubar, but a red notification dot that stays red.

Icon Asset Type Description
apple-Template@2x Template Base apple icon that adapts to menubar theme
apple-red-dot@2x Color Red notification indicator that stays red

Result:

preview-dark-menubar preview-light-menubar

Why?

On macos, the menubar is colored (light or dark) depending on the wallpaper color (not the OS appearance theme). This is the "effective appearance".

At runtime, with multiple displays, each display can have a different wallpaper, so it's possible to have one Tray instance that gets rendered on both light and dark menubars simultaneously. The "solution" for this is to use template images, but they cannot have colors (only black+alpha).

The moment you'd like an icon that adapts to different menubars and also has some colored indicator, I don't think there's a way to achieve that until now (see issue #25478).

How?

It's all based on the fact that NSImage can have a drawing handler, which provides access to the appearance context at draw time. In other words, when macos comes to draw our tray icon, the underlying NSImage knows where it's being rendered and can adapt on the fly.

High-level approach

  1. Pre-render light and dark versions: For each layer in the input, we check if it's a template image. Template layers are manually tinted (black for light mode, white for dark mode), while non-template layers are used as-is. This gives us two complete composited images: one for light menubars, one for dark menubars.

  2. Create an adaptive NSImage: We create a final NSImage with a drawingHandler that:

    • Queries NSAppearance.currentDrawingAppearance to determine the effective appearance of the context where it's being drawn.
    • Checks if that appearance is dark or light.
    • Draws the appropriate pre-rendered version (light or dark).
  3. Let macos do the rest: The beauty of this is that the drawing handler is invoked by macOS wherever the tray icon needs to be rendered. Since macos controls when and where the NSImage is drawn, the handler automatically has access to the correct appearance context for each menubar.

Why this works across multiple displays

When you have multiple displays with different wallpapers (and thus different menubar colors), macos will call our drawing handler separately for each menubar rendering. Each invocation provides the correct NSAppearance.currentDrawingAppearance for that specific menubar, so the same NSImage instance automatically renders the light version on light menubars and the dark version on dark menubars.

The drawing handler is a property of the NSImage object itself, not tied to any particular display or menubar. This means:

  • No need to track which display a tray icon appears on.
  • No need to manually update the icon when wallpapers change.
  • No need to create multiple tray icons for multiple displays.

macos handles all of this for us by simply calling our handler with the right context whenever a repaint is needed.

Other considerations

  • The NativeImage interface remains untouched -- in fact, we leverage all the neat reading and dealing with various hires/lowres representations of images and whether the image is a template image or not.
  • The existing Tray interface remains untouched -- only extended, meaning even if there were some problems with this approach, the consumers of the existing API should not be affected.
  • Layering of differently-sized layers is handled -- layers mismatched in size are composed by making a canvas that can fit all layers, and centering the layers on like that. The intended use is to only layer images of the same size.
  • Layering of layers with mismatched hires/lowres representations is handled -- layers mismatched in what representations (@1x, @2x, @3x etc) they offer are composed by producing all unique representations that appear across layers, and for each choosing the most appropriate representations for each. In other words, if you're missing a @2x for one of your layers, the @1x rep will be used for that layer in the final @2x composition. The intended use is for all the layers to come with the same set of representations.
  • ⚠️ Wasn't sure if I should be marking this "Experimental".
  • ⚠️ My first PR in electron. I read the docs on contributing, but not sure what's needed to take this to the finish line.

Previews

Basic usecase -- a template apple with a red indicator

Basic use case - a template icon with a colored indicator overlay.

Icon Asset Type Description
apple-Template@2x Template Base apple icon that adapts to menubar theme
apple-red-dot@2x Color Red notification indicator that stays red

Result:

preview-dark-menubar preview-light-menubar

The apple shape changes color based on the menubar theme, while the red dot keeps its color.

Complex usecase -- 5-Layer composition

Going complex with 5 layers - mix of template and color elements.

Layer Icon Asset Type Description
1 apple-body-Template@2x Template Main apple body
2 apple-leaf-Template@2x Template Apple leaf
3 red-dot@2x Color Red status indicator (50% alpha)
4 blue-dot@2x Color Blue status indicator (50% alpha)
5 bellpeppers@2x Color Additional color overlay (44x100px - intentionally oversized for demo)

Result:

apple-complex-complete-@2x

Note

Note, the red and blue indicators in the 5-layer example are deliberately overlapping with the apple and each other. It's to demonstrate that the layers can have alpha (50% alpha in this case) and render over each other just fine.

Video Demonstration:

preview-full-res-cycling-h265.mp4

The video shows the progressive layer composition, including:

  • How layers stack and compose together.
  • Multiple template images layering.
  • Opacity handling across layers.
  • Different layer sizes: The bellpeppers layer is 44x100px while other layers are 44x44px - all layers get centered to the largest canvas

Note on Different-Sized Layers: The API handles layers of different dimensions through defensive programming (automatically centering smaller layers on the larger canvas). This is not an intended feature to rely on, just a safety mechanism. The bellpeppers example (44x100px) demonstrates this defensive behavior when mixed with standard 44x44px layers.

Note

The above screenshots and the video were made with a minimal PoC application
using a local build of electron with the layered images feature. I put it in
its own repo at martin-podlubny/electron-poc-macos-layered-tray-icons.

Alternatives considered

coloredTemplate

Single image, marked with coloredTemplate, where on the fly I'd split up the image into black-ish and colored parts, treating black as template and preserving colored.

tray.setImage(blackAppleWithRedIndicator, { coloredTemplate: true })

After playing with CoreImage, Metal, and various other ways to actually process pixels on the fly, I concluded it'd rather let the user pre-split the images for me. So I abandoned this idea.

explicit dark and light

User would provide dark and light variant of a tray icon, and we'd then still use the NSImage's drawingHandler to draw the correct one on the fly.

tray.setImage({
    light: blackAppleWithRedIndicator,
    dark: whiteAppleWithRedIndicator
})

With the above, I had to get into the game of how to deal with template images being passed in for either the light or dark variant. And I thought it's a shame to no longer get that sweet benefit of template images, where you only produce one variant (the black one) and the framework+OS end up doing the right thing.

I then realized that what I'd really want as a consumer is the ability to define which parts of my tray icon are template, and which are the indicators (so colored). Hence the idea of arbitrary layers, where some can be tempalte images.

Extending tray vs. extending native image

At some point during prototyping this I had all this functionality of an image that is part-template and part-nontemplate as an extension to the NativeImage. Where you could construct a native image out of layers and it would behave the same. But I realized that native images are used for a lot more than just tray icons, and I didn't want to deal with all the potential cases and places outside of a tray icon where a more complicated native image could cause problems.

So I chose to limit this functionality to just the tray -- which is where I initially set out to solve the problem/need for colored indicators in tray icons on macos.

macos only vs. all platforms

While in theory layered images could be neat to have on Windows too, the whole premise of this feature in its current form revolves around how neat but limiting template images on macos are -- a template image is automagically colored based on the effective appearance of the menubar(s) it appears in, but cannot have colors.

With template images existing only on macos, for the I chose to only deal with macos. However, I suspect if this proves to be useful on macos, somebody might come along and open a PR for layered icons in Windows trays too!

tray.getEffectiveAppearance() and on('update-icon-for-effective-appearance')

Issue #25478 actually discusses exposing the effective appearance (of a menubar? trayicon?) as a means of solving the same problem of wanting a tray icon that looks good both on light and dark menubars but comes with a colored indicator (red dot).

So I had a stab at that and did manage to expose the effective appearance of our tray menubar item. Under the hood, our Tray becomes a NSStatusItem, which has a button prop, and that button seems to know it's effectiveAppearance.

So I exposed it via tray.getEffectiveAppearance(), and any changes to it via on('update-icon-for-effective-appearance'), but... It gets very complicated very quickly the moment we bring back the problem that:

On macos, the menubar is colored (light or dark) depending on the wallpaper color (not the OS appearance theme). At runtime, with multiple displays, each display can have a different wallpaper,so it's possible to have one Tray instance that gets rendered on both light and dark menubars simultaneously.

Meaning there is no single tray.getEffectiveAppearance() that makes sense, because that one Tray->NSStatusItem->button sort of has multiple effective appearances.

With on('update-icon-for-effective-appearance') I was able to achieve the correct outcome (a black/white icon with a red dot indicator), but for janky reasons:

The effective appearance of that menubar item would change whenever macos needed to re-draw the icon on multiple displays. The even firing for every "about to be drawn" menubar allowed me to swap in the correct icon, macos rendering and caching it, and then repeating it for my N screens. Thanks to this caching, the outcome was that all the menubars had the icon of a correct color, but only just because of timing, caching, and imho undocumented behavior of how NSStatusItem is drawn.

If anybody's interested, I got a branch on my fork called macos-tray-status-item-effective-appearance. But TL;DR based on my stab at this is -- it's messy because on JS side we have one Tray, while on macos that ends up rendered on multiple menubars with potentially different effective appearances.


Checklist

Release Notes

Notes: Added layered tray icons feature on macos.

Introduces an alternative way of setting tray icons on macos, where
instead of providing one image (that has to me a template image or
not), we can provide an array of layers, which will be composed
over each other in order, where come can be template, some not,
and the behavior when rendered by the NSStatusItem is preserved
and the template parts still tint white/black depending on
the effective appearance, but the colored parts stayed colored.
@welcome
Copy link

welcome bot commented Oct 30, 2025

💖 Thanks for opening this pull request! 💖

Semantic PR titles

We use semantic commit messages to streamline the release process. Before your pull request can be merged, you should update your pull request title to start with a semantic prefix.

Examples of commit messages with semantic prefixes:

  • fix: don't overwrite prevent_default if default wasn't prevented
  • feat: add app.isPackaged() method
  • docs: app.isDefaultProtocolClient is now available on Linux

Commit signing

This repo enforces commit signatures for all incoming PRs.
To sign your commits, see GitHub's documentation on Telling Git about your signing key.

PR tips

Things that will help get your PR across the finish line:

  • Follow the JavaScript, C++, and Python coding style.
  • Run npm run lint locally to catch formatting errors earlier.
  • Document any user-facing changes you've made following the documentation styleguide.
  • Include tests when adding/changing behavior.
  • Include screenshots and animated GIFs whenever possible.

We get a lot of pull requests on this repo, so please be patient and we will get back to you as soon as we can.

@electron-cation electron-cation bot added the new-pr 🌱 PR opened recently label Oct 30, 2025
@martin-podlubny martin-podlubny marked this pull request as ready for review October 30, 2025 12:39
@codebytere
Copy link
Member

hey @martin-podlubny - thanks for this! We'll do our best to get some eyes on it before too long :)

docs/api/tray.md Outdated
Comment on lines 81 to 82
* `image` ([NativeImage](native-image.md) | string | Object)
* `layers` ([NativeImage](native-image.md) | string)[] _macOS_ - An array of images to be composited as layers for the tray icon. Each image will be drawn on top of the previous one. This allows composing template images with non-template images (e.g., colored badges or indicators). Template images will automatically adapt to appear correctly regardless of the effective appearance where the tray icon is rendered, which can vary per screen.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: our bespoke Markdown-to-types parser doesn't get the syntax that you wrote (although it's human-readable).

https://github.com/electron/electron/actions/runs/18940338093/attempts/2#summary-54089737711

I think with the current API, [NativeImage](native-image.md) | [NativeImage](native-image.md)[] | string might work but it unfortunately doesn't handle the platform notice very well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I see. And also see I can run npm run create-typescript-definitions locally to see what comes out 🎉

Tbh, I'm not sure what's the best way to surface this functionality and, by extension, the generated types. I'd be happy to take an array of layers rather than an object with a layers property if that makes types easier. That being said, I gave it a shot, and didn't come up with a neat way to achieve that either 🤔

Expand to see various attempts at taking in layers as an array.

I tried massaging the markdown to get

setImage(image: NativeImage | string | (NativeImage | string)[]): void;

but couldn't in a neat way. The naive md of

#### `tray.setImage(image)`

* `image` ([NativeImage](native-image.md) | string | ([NativeImage](native-image.md) | string)[] _macOS_)
// the layers would be one NativeImage, or an array of strings.
setImage(image: (NativeImage) | (string) | ((NativeImage) | (string)[])): void;

The above isn't great.. With enough parentheses I can get it to allow an array of layers:

#### `tray.setImage(image)`

* `image` ([NativeImage](native-image.md) | string | (([NativeImage](native-image.md) | string))[] _macOS_)
setImage(image: (NativeImage) | (string) | (((NativeImage) | (string))[])): void;

The extra parens make this harder to read, but could work.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way I could make this work was by introducing additional type along the lines of LayeredTrayImage. So far, this is my favorite way to surface this new feature. But again, there might be reasons to avoid doing this I'm not aware of.

#### `tray.setImage(image)`

* `image` ([NativeImage](native-image.md) | string | [LayeredTrayImage](structures/layered-tray-image.md) _macOS_)
# LayeredTrayImage Object

* `layers` ([NativeImage](native-image.md) | string)[] - An array of images that will be composed into a single tray icon. Each image in the array will be drawn on top of the previous one, allowing you to compose template images with non-template images (e.g., colored badges or indicators). The array can contain a mix of `NativeImage` instances and string paths.
// electron.d.ts

interface LayeredTrayImage {
  // Docs: https://electronjs.org/docs/api/structures/layered-tray-image

  /**
   * An array of images that will be composed into a single tray icon. Each image in
   * the array will be drawn on top of the previous one, allowing you to compose
   * template images with non-template images (e.g., colored badges or indicators).
   * The array can contain a mix of `NativeImage` instances and string paths.
   */
  layers: Array<(NativeImage) | (string)>;
}
  
setImage(image: (NativeImage) | (string) | (LayeredTrayImage)): void;

Have a look pls, let me know what you think 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👆 did that in 3b8649e

@martin-podlubny martin-podlubny force-pushed the macos-layered-tray-icons branch from b8141f1 to 3fb7c8f Compare November 3, 2025 09:48
@codebytere codebytere requested a review from erickzhao November 3, 2025 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants