← Build log

Tauri Tray Icons Look Small on macOS: The 18pt Floor Hidden Inside tray-icon

June 2026
SoloBuilderTauriRustmacOSDesktopAppDevLocalFirst

The exact line in tray-icon 0.21.3 that decides your menu-bar icon’s displayed size — and the PNG-sizing rule that actually helps once you know it.

Published: June 2026 Tags: #SoloBuilder #Tauri #Rust #macOS #DesktopAppDev #LocalFirst


The “So What?” - Why You Should Care

Bottom Line Value: A concrete, version-pinned explanation of why your Tauri menu-bar icon looks smaller than your Electron one — and a 36×36 PNG rule that gives you sharp Retina rendering without chasing the wrong fix for an hour.

What You’ll Gain:

  • The exact crate, version, and line number that hardcodes Tauri’s macOS tray-icon size
  • The PNG dimensions that produce sharp icons on Retina (it isn’t 22, 32, or 44)
  • A 5-minute confirmation procedure so you know the floor before you start resizing assets
  • A decision rule for whether to ship the override or accept the size shift on a port
  • ROI: 60–90 minutes saved per Tauri port, plus the user-trust win of not shipping a “regression” you didn’t see coming

Who This Helps: Solo builders porting a macOS desktop app from Electron to Tauri 2. Anyone whose Tauri tray icon looks 1–2 pixels smaller and softer than the prior build, and who’s been resizing PNGs trying to fix it.

Why Bother: The Tauri 2 docs don’t mention this floor. Stack Overflow’s answers point at PNG resizing, which can’t help. You’ll burn an hour iterating in the wrong direction before realizing the size cap is upstream and unconfigurable.


TLDR: The Whole Finding in 4 Steps

  1. Reproduce the symptom — install your Electron app and your Tauri build side-by-side; observe the Tauri tray icon is ~2pt smaller. (5 min)
  2. Read the upstream source — ~/.cargo/registry/src/index.crates.io-*/tray-icon-0.21.3/src/platform_impl/macos/mod.rs:279 hardcodes icon_height: f64 = 18.0. (10 min)
  3. Ship the sharpness fix — render tray PNGs at 36×36 (2× the 18pt logical target) so Retina rendering is exactly 1:1. (15 min)
  4. Decide on the size fix — accept the 4-pixel difference, or write a post-tray-creation objc2 setSize override that has to be re-applied after every state transition. (varies)

Result: Sharp tray icon on Retina, no more wasted resize iterations, and an explicit decision (not a regression report) about whether to invest in the size override.


The Moment You’re Probably In

You’re porting a macOS desktop app from Electron to Tauri 2. The Electron build has been in production. Users know what its menu bar icon looks like.

You copy the exact same tray-idle-20x20.png file into the Tauri repo’s src-tauri/icons/. Byte-identical. Same dimensions, same DPI, same template flag. You rebuild and look at the menu bar with both apps running. The Tauri icon is visibly smaller. About 2 points. Softer around the edges.

Two pixels doesn’t sound like a problem until you’ve stared at it for 15 minutes.

Then this happens:

  • You try a 44×44 PNG, hoping Tauri respects pixel dimensions. The icon gets bigger than Electron’s instead. Overshot.
  • You try 22×22, expecting “natural macOS menu-bar size.” No visible change from 44×44.
  • You try 36×36. No visible change from 22×22.
  • You realize there’s a cap somewhere between 20 and 22 and you’ve been binary-searching it for 90 minutes without reading the source.

If you’re in this loop, stop. The cap is in tray-icon 0.21.3. Here’s the line.


Step 1: Confirm the Symptom (5 minutes)

You need both builds running on the same machine to verify this is the actual gap before you waste time on it.

  • Install your existing Electron app and your dev Tauri build.
  • Open both. Look at the menu bar with both tray icons visible.
  • If the Tauri icon is ~2 points shorter and the inside glyph reads as “compressed,” you’re hitting the 18pt floor described below.
  • If the Tauri icon is wildly different in size (4+ points off), you’re hitting a different issue — likely your PNG has an embedded pHYs chunk specifying a non-default DPI, or your icon_as_template(true) flag is mismatched. That’s not what this post fixes.

If the gap is the 1–2pt subtle kind, proceed.


Step 2: Read the Upstream Source (10 minutes)

Navigate to your local Cargo registry and open the tray-icon crate source:

cd ~/.cargo/registry/src/index.crates.io-*/tray-icon-0.21.3/src/platform_impl/macos/
grep -n setSize mod.rs

You’ll find two hits. The load-bearing one is inside set_icon_for_ns_status_item_button:

// tray-icon-0.21.3/src/platform_impl/macos/mod.rs:279
let icon_height: f64 = 18.0;
let icon_width: f64 = (width as f64) / (height as f64 / icon_height);

unsafe {
    let nsdata = NSData::from_vec(png_icon);
    let nsimage = NSImage::initWithData(NSImage::alloc(), &nsdata).unwrap();
    let new_size = NSSize::new(icon_width, icon_height);

    button.setImage(Some(&nsimage));
    nsimage.setSize(new_size);
    button.setImagePosition(NSCellImagePosition::ImageLeft);
    nsimage.setTemplate(icon_is_template);
}

That let icon_height: f64 = 18.0; is the floor. Every tray icon, regardless of source PNG dimensions, gets resized to 18 points tall (width scales proportionally to preserve aspect ratio).

End-to-end, here’s what happens to your 20×20 PNG:

  1. Your 20×20 PNG → NSImage created at native 20pt × 20pt logical size.
  2. nsimage.setSize(NSSize { width: 18.0, height: 18.0 }) resizes the image’s logical size to 18pt × 18pt.
  3. macOS NSStatusItem draws it at 18pt × 18pt in the menu bar.
  4. On Retina, 18pt × 2 = 36 screen pixels. Your 20-pixel bitmap fills that 36-pixel area. Fractional upscale. Visible softness.

Electron uses a different code path (template image auto-fitting in NSStatusItem) that lets the NSImage natural size win, and lands at roughly 20pt × 20pt on screen.

2pt difference. ~4 screen pixels on Retina. That’s the gap you’ve been chasing.


Step 3: Ship the Sharpness Fix at 36×36 (15 minutes)

The sharpness problem is independent of the size problem and you should fix it first.

The crate calls setSize(18, 18) and macOS then renders at 18pt × 18pt = 36 pixels on Retina. If your source bitmap is exactly 36×36, you get 1:1 pixel mapping — no fractional scaling, no softness.

If you have an SVG source (most projects do), render at 36×36:

# If you have rsvg-convert:
rsvg-convert -w 36 -h 36 tray-idle.svg -o tray-idle.png

# Or use any Chromium-based renderer (Playwright headless works fine):
# inject the SVG into a 36×36 canvas, drawImage, toDataURL('image/png')

If you only have a 20×20 raster, upscaling to 36×36 with sips or any bilinear/Lanczos resampler gives you a slightly softer result than rendering from SVG. Re-render from vector if you possibly can.

Drop the new 36×36 PNG into src-tauri/icons/tray-idle.png (and the equivalent for each state). The include_bytes! call in your tray.rs will pick it up on the next cargo build.

Verify in the menu bar. You should see the same 18pt-tall icon as before, but crisp at every edge on Retina. The softness goes away. The size doesn’t change.


Step 4: Decide on the Size Override (varies)

If 18pt is acceptable, you’re done. Most ports should land here. Users notice the sharpness fix more than they notice the 2pt size shift.

If you absolutely need pixel-parity with Electron, you have three options:

  1. Override after tray creation with objc2. Grab NSStatusItem.button.image and call setSize with a larger NSSize (typically 22.0). Has to be re-applied after every set_icon call (every state change). Add it to whatever helper wraps your tray-state updates. Brittle but possible.

  2. Vendor or fork tray-icon. Patch the 18.0 constant. Now you maintain a fork forever. Not recommended unless you’re already vendoring for other reasons.

  3. File a PR upstream. A configurable max-height parameter would help every Tauri tray app on macOS. The hardcoded default is reasonable; the missing override is the gap.

I shipped option 1 in a different project and option “accept the shift” in this one. The override works but adds a maintenance surface for what is, honestly, ~4 pixels. Pick based on whether your users will report it.


What This Doesn’t Fix

  • The size cap is real and unconfigurable through the Tauri 2 / tray-icon public API. No environment variable, no config flag. The override paths above are the only escape.
  • PNG metadata isn’t the problem. I tested with embedded pHYs chunks at various DPIs. The setSize(18, 18) call overrides whatever the PNG claims. Don’t bother editing PNG DPI metadata.
  • icon_as_template(true) doesn’t affect size. It affects tinting (macOS recolors template images to match the menu bar text color). The 18pt floor applies whether the icon is a template or not.
  • This is macOS-only. The same crate has different platform impls for Windows and Linux. The 18pt hardcode is in the macOS file specifically.
  • Future tray-icon versions may change this. I verified on 0.21.3, current as of June 2026. Check the actual line if you’re on a newer version.

Try It This Week

Today (20 minutes):

  • Open your menu bar with your existing Electron app and your Tauri dev build side-by-side. Note the gap.
  • cd ~/.cargo/registry/src/index.crates.io-*/tray-icon-*/src/platform_impl/macos/ and confirm the icon_height line is still there in whatever version you’re on.

Tomorrow (30 minutes):

  • Render fresh 36×36 PNGs for each tray state from your SVG source.
  • Drop them into src-tauri/icons/.
  • Rebuild. Verify sharpness in the actual Tauri window, not just the dev server preview.

Later this week (decision call):

  • Look at the menu bar one more time. Ask: will users report the 2pt difference? Or is the sharpness fix enough?
  • If yes-report: write the objc2 override and put it in your tray-state-change helper.
  • If no-report: ship the 36×36 PNGs alone and move on to features users care about more.

The Takeaway

The 18pt floor is not a bug. It’s a deliberate choice by the tray-icon crate to enforce consistent menu-bar sizing across applications. Reasonable default. The gap is that it’s not overridable through the public API, and isn’t documented anywhere a Tauri app developer would naturally look.

The general principle: when a behavior contradicts what you expect from the docs, the docs are incomplete and the source is the source of truth. I burned 90 minutes on PNG dimensions before reading 20 lines of upstream Rust. Reading the dep source is cheap. The reflex to do it earlier is the actual skill.

For Tauri ports specifically: budget a half-day of “things that should be 1:1 are subtly off” diagnosis before shipping to existing users. The tray icon was mine. There will be others. Each one is cheap once you know to look in the dep source instead of the docs.