Display scale
Every length in react-x11 is a logical pixel: a unit sized so that
fontSize: 14 is comfortably readable at the distance the display is
actually viewed from. On the panels of the last decade one logical pixel is
two device pixels — or 1.5, or 1.25 — and the renderer multiplies exactly
once, on the way to the server. An app says nothing and gets a UI the same
physical size on a 2013 monitor and a retina laptop.
const root = await createRoot(); // scale: 'auto' is the default
const root = await createRoot({ scale: 2 }); // pin it
REACT_X11_SCALE=1.5 node app.js // the user outranks everybody
import { useScale } from 'react-x11';
const scale = useScale(); // 1, 2, 1.5... static for the life of the root
What is logical and what is device
Logical — everything application code touches:
- every style length (
width,padding,fontSize,borderRadius, the lengths insideboxShadowand gradient stops,hitSlop…) - window geometry props (
width,height,x,y,minWidth…) and theonResizepayload - event coordinates:
ev.x/ev.y/ev.localX/ev.localY, wheeldeltaX/deltaY, dragscreenX/screenY getClientRects(),measure(),useScreens()rects,anchorRect/centerRect/anchorArea- the scroll surface:
scrollTo()/scrollBy()arguments,onScrollandonViewportpayloads - the theme:
DefaultTheme.fontSizeis 14 and stays 14
Device — the renderer's internals and the deliberate escape hatches:
node.abs, yoga's computed layout, everything paintednode.scrollX/scrollY/contentWidth/contentHeightread directly off a ref (positions in the rendered surface; use theonScrollpayload for logical math)<canvas onDraw>: the browser's own canvas contract — the box arrives in device pixels and the payload carriesscale, so detail is drawn on the real grid. It is deliberately not pre-scaled withctx.scale(): text drawn through a scaled transform moves without growing.- a registered element's
paint()andhitTest()(see extending.md):this.styleandthis.absarrive already device, and so arepaintDamage(), the rects handed toinvalidateandscrollContentsand the rects of an a11y scene; synthetic events are logical, andthis.scaleis on every node for the constants that never pass through a style and the event coordinate on its way toabs.
Fonts are the point of the whole exercise: a fontSize: 14 at scale 2 is
shaped, measured and rasterized at 28px. Text on a retina panel is sharper,
not bigger-blurrier.
Fractional scales (1.25, 1.5) work end to end, with the usual X11
caveat: a 1-logical-px hairline at 1.25x is 1 or 2 device pixels depending
on where it lands. Integer scales have no such seams.
"Logical" is per node rather than per window as soon as a scale prop is in
the tree: everything in the first list is divided by the scale of the node
it is about, which is the display's until an enclosing scale says
otherwise. See A subtree of its own below.
How 'auto' decides
X11 has no scale protocol, so 'auto' consults these in order and takes the
first that answers:
- Environment —
REACT_X11_SCALE(wins over everything, an explicitscale:number included: it is the accessibility escape hatch), thenGDK_SCALE, thenQT_SCALE_FACTOR. - XSETTINGS —
Gdk/WindowScalingFactorwhen it is 2+, thenXft/DPI: what the desktop's own settings dialog writes. RESOURCE_MANAGER—Xft.dpi, thexrdbconvention.- RandR millimetres — the panel's physical size against its pixel size, per output, under mutter's viewing-distance model: the DPI that counts as 1x is 135 under a 20″ diagonal and 110 over it, snapped to quarter steps in [1, 3]. A 27″ 2560×1440 monitor is 1x, a 27″ 4K 1.5x, a 16″ MacBook panel 2x.
- The resolution class — when the millimetres are absent or not credible: a grid ≥3000 wide, or taller than 1600 on its short side, is 2x, and everything else 1x. 2560×1440 and 2560×1600 stay 1x.
- 1.
96 is not an answer: Xft.dpi: 96 and Gdk/WindowScalingFactor: 1 are
what daemons publish when nobody opened the dialog, so they fall through to
the hardware rather than pinning an unconfigured 4K laptop to 1x. And
millimetres are believed only when credible: zeroes, aspect ratios
posing as sizes, a physical/pixel aspect mismatch and virtual machines'
monitors (by EDID — QEMU, VMware, VirtualBox, Parallels, Hyper-V — and by
connector name, Virtual-*, qxl, …) fall through to rung 5. So do
XWAYLAND* outputs' millimetres: the compositor owns scaling there and
publishes its decision through rung 2.
Servers that are not describing hardware
Rungs 4–5 read a panel, so they are skipped where the connection has none:
- XQuartz (the
Apple-WMextension) hands X macOS's point space, already normalised for density, and macOS scales the window onto whichever panel it lands on. The answer is 1x, with the sourcexquartz. - A single synthetic output covering every monitor — Xvfb, x11vnc, TigerVNC, Xephyr and pre-RandR drivers — would read the union of two 1x monitors as a retina panel, so rung 5 is skipped when Xinerama reports more heads than outputs. Credible millimetres (rung 4) still count.
REACT_X11_SCALE, GDK_SCALE and a desktop's own XSETTINGS still win over
both. REACT_X11_DEBUG_SCALE=1 in an app prints how the ladder resolved.
On Wayland
A compositor has a scale protocol, and its answer is the display scale:
fractional-scale-v1 where it is offered (1.25, 1.5…), else
wl_surface.preferred_buffer_scale, else the integer wl_output.scale of
the output a window is on. Buffers are drawn at that density, so text is
as sharp at 1.5 as at 2. Two things still outrank it, as on X11:
REACT_X11_SCALE, thencreateRoot({ scale })pin the factor, absolutely: device (buffer) pixels per logical pixel. On a compositor at 1x,REACT_X11_SCALE=2is a window twice the size drawn at full resolution, not a small one stretched.- The resolution class, when the compositor says 1. When every output is at 1x and the largest has no credible millimetres but a retina-class grid — a VM window over a retina panel — the layout is 2x, as the X11 ladder answers for the same screen through Xwayland. A compositor that scales any output, or credible millimetres, keep the compositor's answer.
The frame a window draws for itself follows the compositor, not the
layout: it is drawn at the desktop's scale, beside every other window's.
REACT_X11_DEBUG_SCALE=1 prints which of these answered.
Several monitors
Rungs 4–5 are computed for every connected output. The root's scale — the one layout and paint use — is the primary output's (the largest, where nothing is flagged primary), as GNOME does on X11: one scale for the session.
The per-output answers ride on useScreens() — every entry carries its own
scale — so an app that places windows can do better, and a window can be
pinned with createRoot({ scale }) per root. A window is not re-scaled
as it is dragged between mismatched monitors: X11 has one coordinate space
and no per-window scale protocol, so the scale is chosen at creation and
static after.
It is static for the session too: a mid-session change of Xft.dpi or a
monitor swap does not re-scale running windows. Restart the app.
A subtree of its own
The display scale is one number for the whole window. scale on an element
is a second factor for one subtree of it:
<box scale={zoom} style={{ width: w / zoom, height: h / zoom }}>
<NodeBody /> // written in plain logical pixels, unaware of the zoom
</box>
The semantics are CSS's zoom, not a transform:
- A node's effective scale is its parent's times its own
scaleprop, so nested props multiply and the display scale is the floor they multiply.node.scaleis that number, and everything the display scale reaches — styles, the caret and scrollbars, shadows and gradients, a registered element's ownpaint()— follows it. - The node's own style scales too.
scale={2}on a box withpadding: 8gets 16 device pixels of padding, not 8 with bigger children inside it. That is what makes the caller's job the one-liner above: divide the size you want the box to occupy by the zoom, and write everything inside it at 1x. - Text is shaped at the size it is drawn at. A
fontSize: 14insidescale={2}is shaped, measured and rasterized at 28 — the same thing the display scale does, for the same reason. A zoomed-in graph is not a stretched screenshot of a zoomed-out one. - Yoga lays out the scaled numbers like any others, so a zoomed subtree wraps, flexes and measures its content floors at the size it is drawn.
What it is for: an element that owns a viewport — a graph pane, a canvas, a map — and mounts application content inside it. The pane draws its own world, and the React subtree beside it follows the same zoom, laid out, drawn and hit at the zoomed size.
What a boundary costs
Events are in the target's unit. ev.x, ev.y, localX/localY and
getClientRects() on a node inside a zoomed subtree are all divided by
that node's scale, so they agree with each other and with the styles it
was written in — a handler inside the subtree needs to know nothing. The
trade-off is CSS zoom's: a handler on an unzoomed ancestor reads a
coordinate in the zoomed descendant's unit, because the target is what
decides. When a pane needs its own coordinates for a hit that landed inside
the content it hosts, ev.nativeEvent is the way back — device pixels of
the window, which / node.scale converts to whichever unit is wanted.
screenX/screenY are unaffected: they are desktop coordinates, and the
desktop has one scale.
A wheel notch scrolls 48 of the target's logical pixels, so content under
the pointer travels the distance the zoom says it should.
A real X window is its own root. <window> and <popup> take no
scale prop and ignore an enclosing one: their geometry is the server's, a
window manager sees no zoom, and a menu opened from a zoomed card should
come up at the size the rest of the app's menus are. <glarea> and
<foreign> do inherit — their boxes are laid out by the parent like any
other child's.
Quantise a gesture-driven zoom. Every distinct factor is a distinct
font size to shape and cache, so a wheel handler feeding the raw accumulator
into scale makes a new set of glyphs per frame. Round to a step — 5%, or
a ladder of preset zooms — and the cache does its job.
In tests
The headless harnesses resolve to exactly 1 — a mock app has no display to
ask — so a geometry assertion means its numbers literally.
createRoot({ app, scale: 2 }) opts a test in, and so does
renderX11(element, { scale: 2 }) from react-x11/test.
Design notes — the ladder's rationale, prior art, and why lengths are multiplied rather than the context transformed: design/scale.md.