Skip to main content

TrayHost

import { TrayHost } from '@react-x11/components/tray-host';

<TrayHost
orientation="horizontal"
iconSize={22}
onDock={(icon) => log(`docked ${icon.id}`)}
onUndock={(icon) => log(`gone ${icon.id}`)}
/>;

The system tray, as a component: applications hand it their icon windows and it draws them in a row. The same XEmbed protocol <Terminal> and <MediaPlayer> use, pointed the other way — those spawn a program into a container they own; a tray is handed windows by applications that were already running. The system tray spec is XEmbed's biggest surviving consumer.

Mounting it takes the _NET_SYSTEM_TRAY_S<screen> selection with a real server timestamp, publishes _NET_SYSTEM_TRAY_ORIENTATION, and broadcasts MANAGER to the root — which is what makes applications that started before the panel go and dock themselves. Each SYSTEM_TRAY_REQUEST_DOCK becomes one <foreign>; unmounting gives the selection back and hands every client to the root untouched.

Props

PropTypeNotes
orientation'horizontal' (default) | 'vertical'Which way the icons run, and what _NET_SYSTEM_TRAY_ORIENTATION says so an icon can draw itself to match.
iconSizenumberThe square each icon is laid out in, in pixels. Default 22.
spacingnumberBetween icons. Default 2.
screennumberWhich screen's selection to own. Default 0.
sort(a: TrayIcon, b: TrayIcon) => numberRender order. Icons are otherwise in the order they docked. A comparator, not a list — see below.
notifybooleanFalse stops balloon messages being forwarded to the notification service. Ignored when onMessage is given, which already replaces it.
appNamestringWho a forwarded balloon message is attributed to. Default 'System Tray'.
fallbackReactNodeRendered instead of the tray when another host owns the selection.
styleStyle | Style[]
refRef<TrayHostHandle>

Events

PropTypeNotes
onDock(icon: TrayIcon) => voidAn application asked to be in the tray, and now is.
onUndock(icon: TrayIcon) => voidIts window went away — the ordinary end of a tray icon's life, not an error. Also fires for every icon when the selection is lost.
onConflict(info: TrayConflict) => voidAnother tray already owns this screen's selection, so this one embeds nothing.
onStatusChange(status: TrayStatus) => void'starting' | 'owned' | 'conflict' | 'released' | 'unavailable'.
onMessage(message: TrayMessage) => voidA balloon message. Providing this turns off the default, which is to forward the text to the desktop's notification service.
onCancelMessage(info) => voidA message was withdrawn before it finished arriving.
onError(err: Error) => voidFailures on the wire, and a notification that could not be delivered. Without a handler none of it is reported anywhere.

TrayHostHandle

interface TrayHostHandle {
readonly status: TrayStatus;
readonly conflict: TrayConflict | null;
readonly icons: readonly TrayIcon[];
readonly windowId: number | null; // the manager selection window
}

windowId is the address clients send opcodes to — useful for a test, and for a diagnostic that wants to answer "who is the tray?".

Four decisions, not gaps

  • One tray per display, and a second one says so. If the selection is already owned, the host reports it through onConflict, renders fallback, and embeds nothing. A second panel is a configuration mistake, not an exception to throw. Losing the selection later — another tray started — releases every icon, because a panel still drawing icons it no longer holds is the failure users report as "my tray is empty".
  • A visual is advertised only when there is one. _NET_SYSTEM_TRAY_VISUAL appears only when the window the icons are embedded into genuinely carries a 32-bit ARGB visual. Put the tray in a <window transparent> and icons get real translucency; anywhere else they fall back to guessing a background rather than drawing black boxes.
  • Icons are not tab stops. Every icon is focusable={false}: a tray icon is a click target, and Tab walking through eleven of them — several of which may not have mapped yet — is the worst version of this.
  • Reordering moves nodes, it does not re-embed clients. sort is a comparator rather than a list you rebuild, because each <foreign> is keyed on the window id and its windowId never changes. Unmounting one node and mounting another with the same id parks the client at the root long enough for a window manager to frame it, and the new node then reports onClientGone for a live window.

Balloon messages

SYSTEM_TRAY_BEGIN_MESSAGE is the pre-notification-daemon way an icon says something. The text arrives in 20-byte ClientMessage chunks; this component reassembles them, decodes UTF-8 (malformed bytes do not throw), and by default forwards the result to the desktop's notification service.

interface TrayMessage {
readonly windowId: number; // the icon that is talking
readonly id: number; // the client's own id — what a cancel names
readonly timeout: number; // ms; 0 means "until dismissed", as the spec means it
readonly text: string;
}

Pass onMessage to draw your own bubble instead — that turns the forwarding off, because a panel that draws its own bubble should not also raise a notification. Pass notify={false} to drop them entirely.

Lower-level exports

TrayManager is the protocol on its own, without React: selection ownership, MANAGER broadcast, dock requests, SelectionClear. BalloonAssembler reassembles the chunked messages and BalloonNotifier delivers them. Also exported: ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL, SYSTEM_TRAY_BEGIN_MESSAGE, SYSTEM_TRAY_CANCEL_MESSAGE, SYSTEM_TRAY_REQUEST_DOCK, argbVisualOf, orientationValue and selectionNameFor.

Not in this component

StatusNotifierItem. Modern applications publish a tray icon over D-Bus, a complete panel supports both, and SNI shares nothing with this except intent — it pairs with core's dbusmenu.js, not with <foreign>. It belongs beside <TrayHost> rather than inside it, and is planned as its own module.

Example

npm run examples:tray-host is a one-row panel that is the tray for its display. It needs a real $DISPLAY with no tray on it yet.