Components
Every component takes style and merges it after its own, so an override
wins by position rather than by clobbering a computed value:
<Button
label="Sign"
style={{ marginTop: 8, ':hover': { borderColor: '#333' } }}
/>
Props that a component consumes itself are never style — ProgressBar's
color, Dialog's width/height (a dialog is a real popup window and
needs its geometry up front), ContextMenu's fontSize (it measures labels
with it). Everything else is forwarded to the host box. See
styling.md.
Widget components are plain React built on the host elements — no reconciler support involved. They live in the package root export.
All of them are screen-reader ready: roles, names, states and AT-driven
control come built in, and an aria-label (or a label child) is the only
thing a widget can need from you — accessibility.md
lists what each one announces.
Theming
Widgets follow the desktop with nothing declared — dark on a dark desktop, light on a light one, the same way a GTK or Qt app does. See system appearance for how that is worked out and how to pin or override it.
<ThemeProvider value={palette}> gives every widget beneath it a palette,
layered over the scheme in force — so a partial one names what your app
changes and everything else keeps following. It carries shape as well as
colour — corner radius, border weight, text size and the padding inside a
control are most of what separates one platform's controls from another's:
| token | |
|---|---|
background surface | the ground, and what is raised on it |
text textMuted border | ink, secondary ink, edges |
accent accentHover accentText | primary buttons, checks, fills |
hoverBackground hoverText | selected rows, menu highlights |
surfaceHover track borderFocus | hover fills, tracks, focus |
danger success warning info (+…Text) | what the app says with colour |
accentActive surfaceActive textMutedActive dangerActive | the pressed step of each fill |
radius radiusSmall borderWidth | control shape |
radiusPopup radiusPopupItem radiusTooltip | floating-surface shape |
fontSize paddingX paddingY | control size |
fontFamily monoFamily | the app's two faces |
direction | which way the app reads |
The ground and what is on it
background is what the window is — the fill under everything, painted
by the window itself. surface is what is raised off it: a control's fill, a
card, the sheet a menu or a dialog is drawn on. The light palette makes them
the same colour, because a white app on a white ground is what it has always
been; the dark one lifts surface a step, because a card at the ground's own
colour is a card you cannot see.
A palette that has one ground says so by naming one: surface follows
background unless you name it, so a theme that only recolours the app
keeps working and only a design that really raises its cards has a second
token to fill in.
<ThemeProvider value={{ background: '#f6f8fa', surface: '#ffffff' }}>
surfaceHover and surfaceActive are that surface's own interaction steps,
which is what they have always been — before there was a surface to be the
steps of.
What the app says with colour
danger, success, warning and info are the four things a screen has to
be able to say: this failed, this worked, look at this, here is a note. They
are what an alert, a badge, a validation message and a toast are coloured
from, and having them as tokens is what keeps a hard-coded #e74c3c — which
is a bruise on a dark desktop — out of application code.
Each works as ink as well as fill: every one clears 4.5:1 against its own palette's ground, so the message under a field and the badge beside it are one token.
<text style={{ color: '$danger' }}>Password too short</text>
<box style={{ backgroundColor: '$danger' }}>
<text style={{ color: '$dangerText' }}>Delete</text>
</box>
For the tinted panel an alert usually wants — a wash of the colour rather than the colour — tint the fill and leave the ink alone:
import { tint } from 'react-x11/style';
<box style={{ backgroundColor: tint(theme.danger, 0.12) }}>
<text style={{ color: '$danger' }}>Could not save</text>
</box>;
Only danger has …Hover and …Active, because a destructive button is the
only status fill anyone presses; the other three are things the app says, not
things the user clicks.
The …Text four are the letters on a status fill, and a palette almost
never sets them: each is derived from the fill it goes on, as the more
legible of that palette's own text and background. So a theme whose
warning is a yellow gets dark letters on it without having thought about
it — and the same rule fixes accentText, which used to be inherited as
white onto accents that could not carry white.
fontFamily and fontSize are the type this app sets, and text that names
neither takes them — a <text>, a <textinput>, a widget's own label. So
<ThemeProvider value={{ fontFamily: 'Inter', fontSize: 16 }}> is where an app
says what it is set in, once, rather than on every label, and a Select's rows
grow with it because a row is its label with even space all round. A fontSize
or fontFamily in a style still wins, the way a style property always wins
over what a node inherits.
The widgets that take a size of their own keep taking it: MenuBar and
ContextMenu have a fontSize prop because they measure their labels before
they have anywhere to measure them in, and Table has rowHeight because
every row has to be the same height for it to skip the ones off screen.
Both are CSS-style family lists — '"JetBrains Mono", monospace' names a
preference and a fallback.
monoFamily is the second face, and nothing unstyled reads it: it is there so
that every code surface in an app — a listing, a log pane, a hex dump, all
written by different components — can say fontFamily: '$monoFamily' and be
set from one place.
Which way it reads
direction is 'ltr' or 'rtl', and it is seeded from the locale: an
app started under an RTL locale is mirrored without being configured, the
way a GTK or Qt one is. Set it here to mirror a whole UI from one place:
<ThemeProvider value={{ direction: settings.rtl ? 'rtl' : 'ltr' }}>
The provider plants the matching direction style property in the node tree
as it goes, so the boxes and the widgets mirror together. That is
why this is the way to do it rather than a bare <box style={{ direction: 'rtl' }}>: yoga mirrors boxes on its own, but which way an arrow key steps,
which way a chevron points and which side a submenu opens on are widget
decisions, read through useDirection(). Under a bare style property the
layout mirrors and those do not.
Most of the set needs no help — Button, Checkbox, Radio, Switch and
ProgressBar are rows and flex ratios, and come out mirrored on their own.
The rest each have one thing yoga could not answer:
| widget | what mirrors |
|---|---|
Slider | the drag, and Left/Right (Up/Down do not) |
Tabs | Left/Right walk the strip the way it is drawn |
Tree | the indent, the twisty, and which arrow opens |
Table | column resizing, the sort mark, the header's scroll |
SplitPane | which pane is first, the drag and its arrows |
MenuBar / ContextMenu | submenus open to the start side, and their arrows |
Calendar / DatePicker | a week runs the other way, and so do Left/Right |
Select, PasswordInput | the field's own insets follow the text |
<textinput> and <textarea> mirror on the inside too — the value, the
placeholder and the caret are against the direction's start edge, and the
value is shaped at the box's base level rather than at its own first strong
character (styling.md). What stays
put: Left/Right and Home/End step through the string rather than across the
screen.
See styling.md for the style property and the logical edges.
paddingY is the space you can see — above the capitals and below the
baseline — not the space plus whatever the font's ascent left over: every
widget label is trimmed to its letters
(styling.md), so a control is its
label band plus this twice. That makes it a larger number than the same look
would need in CSS, and it makes it mean the same thing in every typeface.
Rows on a popup follow the same rule — a menu row and a Select option are
their label with padding all round, so the space above a row's text is the
space beside it. So does <textinput>, which is what keeps a field the same
height as the Button and the Select next to it on a form; give it
paddingY and the three agree by construction
(styling.md).
The …Active four are the colour a control takes while it is held, and
a palette almost never sets them: each is derived from the step the palette's
own hover already makes — accent → accentHover → one more of the same —
so it darkens a light theme and lightens a dark one. Set one explicitly and
it wins. See The press state for why every control has
one.
The radius… three that name a popup are derived too, and from the text
size rather than from radius: a menu is a sheet laid over the window
where a button is a control cut into it, and half the body size is the
number desktops land near — 7px at a 14px body. The other two step in from
there, because a rounded thing inside a rounded thing needs the tighter
curve: radiusPopupItem is the highlight on a menu row, radiusTooltip the
tooltip bubble. A palette that moves fontSize and names none of them gets
all three in proportion; naming one pins it.
radiusPopupItem is a ceiling, not the radius a row gets. A rounded rect
inside a rounded rect only reads as one shape when the two curves share a
centre, which happens exactly when the inner radius is the outer one less the
gap between them — so a row's corner is radiusPopup minus the sheet's
hairline border minus the inset the row is padded in by, and the token caps
it. A theme that wants rounder highlights rounds the sheet they sit on: that
is the only change that can round both and keep them concentric.
They are only ever seen where the display composites — a popup gives up
its corners by not painting them, and with nothing blending them those
pixels are black rather than the desktop, so the widgets ask the window
('@supports transparency', styling.md) and stay square
where the answer is no.
There are two consumers of a palette, and one provider feeds both. Widgets
read it as React context through useTheme(); a $token in a style
resolves against the nearest theme prop in the node tree, which knows
nothing about React (styling.md). ThemeProvider
puts the merged palette on the context and plants it in the tree, so
<ThemeProvider value={dark}>
<box style={{ backgroundColor: '$background' }}>…</box>
</ThemeProvider>
paints the palette rather than nothing. Nesting works the same either way:
an inner provider merges over the outer, as an inner theme prop does.
useTheme() returns the palette in force — complete, whatever the provider
above set — and is the same object token resolution sees:
function Panel() {
const theme = useTheme();
return <box style={{ borderColor: theme.border }} />;
}
It is a bag of tokens, and indexes like one: a component that resolves a name
it was handed rather than one it wrote — a code block reading its own colour
map, a renderer resolving $token itself — writes theme[name] with no cast.
The named tokens keep their types, anything else is unknown and narrows.
What a provider accepts stays closed (Partial<Theme>), because a typo in a
palette has nowhere else to be caught.
The palette reaches the tree on a <box> the provider renders, styled
{ flexGrow: 1 } so an app-level provider fills its parent; pass style to
change that (style={{ flexGrow: 0 }} around a single control). A <window>
may not sit inside a box, so a provider above one plants the prop on the
window itself and renders no box.
Widgets plant the merged palette on their own root node too, so a $token
in a style you pass one resolves even with no provider anywhere.
examples/themes.js has three worked palettes — GitHub, macOS and Windows,
each in light and dark — and npm run examples:theming switches between
them at runtime.
System icons
The glyphs the widgets here are drawn with, exported so an application and a
third-party widget can use the same ones — a toolbar you write and a Select
you did not should not disagree about which way a chevron points.
import { Icon } from 'react-x11';
<Icon name="chevronDown" />
<Icon name="check" size={10} color={theme.accentText} />
| name | |
|---|---|
chevronRight chevronLeft chevronDown chevronUp | more this way: menus, twisties, sort |
check dash dot | chosen, partly chosen, one of many |
close plus moreVertical | dismiss, add, overflow |
eye eyeOff | reveal a secret, hide it again |
| prop | |
|---|---|
name | one of the above; anything else throws |
size | the mark, px. Default: a shade under theme.fontSize |
color | the ink. Default: theme.text |
Everything else goes to the host <canvas>, so a clickable one is
<Icon name="close" onClick={…} focusable />.
size is the mark, not a grid it sits in. This is the one place the set
departs from how lucide and its descendants are drawn: theirs put about 14px
of ink in a 24px box, so you reach for size={20} next to 14px text. Here
the ink runs corner to corner, so size is what you actually see and the
number to pick is the number you want — the Select chevron is
capBand(fontSize) because it should be as wide as the capitals beside it.
A chevron is the one glyph whose two axes differ: the arms are at 45°, so it
is as long as size along the way it points across and half that the other
way. <Icon name="chevronDown" size={10} /> is 10 wide and 5 tall; the same
size of chevronRight is 5 wide and 10 tall.
Affordances, not nouns
The set holds glyphs that say something about the control: there is more here, this one is chosen, this closes, this is hidden. It holds no nouns — no folder, no document, no save, no printer. Those belong to an icon theme (lucide, an XDG icon theme, your own art), they are unbounded in number, and a widget set that starts shipping them has taken on a design system.
That is also the answer to "why is X missing": if X names a thing rather
than an action the control affords, it is not going to be here. Bringing
your own set is expected and supported — a <svg> or a <canvas> goes
anywhere an <Icon> does, and ContextMenu's icon takes either.
The drawings are not themable for the same reason: the geometry is the widget set's vocabulary, and an application that wants a different chevron wants an icon library. Colour and size are yours; the shape is not.
Colour inherits; size does not
color travels down the tree
(styling.md), so an
icon takes the ink of whatever it is written inside and needs nothing said
at the call site:
<box style={{ color: theme.textMuted, ':hover': { color: theme.accent } }}>
<text>Open recent</text>
<Icon name="chevronRight" />
</box>
That covers the hover case too. :hover marks the row, color is
inherited, and the label and the icon both follow — which is why Tree's
twisty and ContextMenu's submenu chevron carry no colour of their own any
more. The color prop is for saying something the surrounding text does
not: a destructive action's mark, or a check drawn on an accent fill.
size does not inherit, and is deliberately not fontSize. A glyph is
a drawing rather than a letter — no baseline to sit on, no ascent to be
measured against — so it takes its default from the palette's fontSize and
stays put when a label beside it shrinks. Pass size for the one icon that
has to be bigger.
What it costs to draw one
Each glyph is a drawing over <canvas mono>
(elements.md), which is a promise that everything it
paints is one colour it did not choose — so one drawing serves every state a
control puts it in, and the paint cache can keep one rendered copy of
chevronDown at 12px for every twisty in a Tree at once.
The entry is coverage, so the colour is applied at composite time and
stays out of the key: one rendered copy of chevronDown at 12px serves the
resting row, the highlighted row, the disabled one and both colour schemes —
the trick the glyph cache runs on text, and the one <svg> gets for a
fill="currentColor" document. The size cannot leave the key, since a
coverage surface is pixels at a fixed size, so one icon at two sizes is two
entries.
The drawings are module-level, so re-rendering a Tree invalidates none of
its twisties: <canvas> compares onDraw by identity, and a fresh closure
per render is a repaint per glyph.
icons is the map of raw drawings, for a widget that wants the glyph
without the component:
import { icons } from 'react-x11';
<canvas
mono
cacheKey="check"
onDraw={icons.check}
style={{ width: 12, height: 12, color: '$textMuted' }}
/>;
They are decoration by default — aria-hidden, because the meaning is
already on the control, in its role and its aria-expanded. Name one
(aria-hidden={false} aria-label="Close") only when the icon is the
control and nothing else says so.
Basic controls
Button, Checkbox, Radio/RadioGroup, Switch and ProgressBar share
one piece of plumbing, useControl(disabled, onActivate): it makes the
control focusable, activates it on click — and so on Space and Enter, which
are a click on anything with an onClick
(events.md), the widgets having no
key mapping of their own any more — sets the pointer cursor, and expresses
hover, press and focus
feedback as :hover/:active/:focus style blocks rather than React state
— so moving the pointer over a control repaints one node instead of
rendering.
Button, Checkbox, Switch and ProgressBar take style, merged after
their own so an override wins by position, and forward any remaining props
to the host box. Radio is the exception: it takes only the props listed
below, and the group around it carries the layout.
import { Button, Checkbox, RadioGroup, Radio, Switch, ProgressBar } from 'react-x11';
<Button primary onPress={save}>
Save
</Button>
<Checkbox checked={wrap} onChange={(ev) => setWrap(ev.value)}>Wrap lines</Checkbox>
<Switch checked={live} onChange={(ev) => setLive(ev.value)} />
<ProgressBar value={0.4} style={{ width: 200 }} />
<RadioGroup value={size} onChange={(ev) => setSize(ev.value)}>
<Radio value="s">Small</Radio>
<Radio value="m">Medium</Radio>
</RadioGroup>;
Label text is the children (or a label prop); a bare string is wrapped in
a <text> for you, so <Button>Save</Button> needs no <text>.
The press state
Every control here activates on the release — that is what a click is. So every one of them also has a distinct look while it is being held, because otherwise a click a user takes half a second over is half a second of a control that has visibly not heard them, and the change, when it comes, reads as the machine being slow rather than the hand being unhurried.
Four states, all different: resting, hovered, held, and hovered again on the release. The held one is drawn on the press even though the press itself does nothing — it acknowledges the input, it does not promise the outcome. A press dragged off the control drops it, and picking the control back up restores it, so the way it looks always agrees with whether releasing there would activate anything.
Nothing is needed to get this: it is what the widgets do. Writing a control
of your own, :active is the state block for it —
styling.md — and the palette's
accentActive/surfaceActive/textMutedActive/dangerActive are the
colours.
The change event, and name
Every value control — Checkbox, Switch, RadioGroup, Select,
Slider — calls onChange(ev) with a change event, the same signature
<textinput> and <textarea> use
(elements.md). The new value is ev.value:
<Checkbox checked={agreed} onChange={(ev) => setAgreed(ev.value)}>
I agree
</Checkbox>
{ type: 'change',
target: { type: 'checkbox', name: 'agree', value: true, checked: true },
currentTarget: /* the same object */,
name: 'agree', value: true }
One signature is the point. It is what lets a form library's handler be passed straight to any control in the library, with no per-widget adapter:
<textinput name="host" value={f.values.host} onChange={f.handleChange} />
<Checkbox name="agree" checked={f.values.agree} onChange={f.handleChange} />
target is a plain descriptor rather than a node, and that is the one place
this differs from the host elements: a widget is several nodes with no single
element holding its value, so there is nothing honest to point at. Its shape
is what formik's handleChange and react-hook-form's event reader
destructure — target.type is how they tell a checkbox from a text field,
which is why it is set even though nothing in react-x11 reads it. There is no
preventDefault: the value has already changed by the time the handler runs.
The line is name. A widget that takes one is a form field and reports
an event. Tabs, Tree, Table and the menus are not form fields — you
would never register a tab strip with formik — and keep their plain callbacks
(Tabs calls onChange(id)).
name is otherwise inert; it exists so a form library has somewhere to put
one. See docs/ecosystem/forms.md.
Button
| prop | |
|---|---|
children / label | the label |
onPress() | click, Space or Enter |
variant | 'solid' (default), 'outline', 'ghost' — the chrome |
size | 'medium' (default) or 'small' — the compact metric |
primary | speaks in the accent: the fill when solid, the ink and border otherwise |
disabled | inert, dimmed, not focusable |
Chrome and colour are two axes, and they compose. variant is how much box
the button carries — solid a fill, outline a border on nothing, ghost
neither, for the affordance that sits inside other content (a ✕ on a chip,
a delete on a form row) and must not add a box to the row it lives in.
primary is whose colours it speaks in. So a dialog footer is an outline
beside a primary solid, and a toolbar's loudest icon is
primary variant="ghost" — every combination keeps the widget states, which
is the point of not hand-drawing the chrome-less ones. All variants keep the
border width, so a mixed row lines up to the pixel.
size="small" halves the control padding — the toolbar and inline-row
metric. It is derived from the palette, so a theme that moves paddingY
moves both sizes together, which a hand-made style={{ height: 22 }} would
not.
An element child inherits the resolved label colour — the ink is set on
the button's box and color inherits (styling.md) — so an
icon+label button is correct by construction in every state, disabled
included, with nothing passed to the icon:
<Button primary disabled={busy} onPress={run}>
<Icon name="chevronRight" />
Continue
</Button>
<Button variant="ghost" size="small" aria-label="Remove" onPress={remove}>
<Icon name="close" />
</Button>
Checkbox
| prop | |
|---|---|
checked | current value (controlled) |
onChange(ev) | a change event; the value is ev.value |
name | field name, for form libraries |
children / label | label to the right of the 16px check well |
disabled | inert, dimmed |
Radio / RadioGroup
RadioGroup takes value, onChange(ev), name, style and any
box props; each Radio takes the value it selects, plus children/label
and disabled — and nothing else, so per-radio styling goes on the group. A
Radio outside a RadioGroup throws rather than silently doing nothing.
name lives on the group the way it does in HTML: the group is the field.
Arrow keys move the selection through the group in mount order, wrapping — Up/Left back, Down/Right forward — which is how a native radio group behaves; click or Space selects the focused one.
Switch
checked, onChange(ev), name and disabled, the same semantics
as Checkbox in a sliding pill. The thumb is absolutely positioned and
animates on left (transition: { left: 120 }) because justifyContent
would flip between the ends with nothing in between to animate — the worked
example in styling.md.
ProgressBar
Determinate with a value, indeterminate without one.
| prop | |
|---|---|
value | 0 to 1, clamped |
indeterminate | working, extent unknown — a block slides across forever |
color | fill colour (defaults to the theme accent) |
trackColor | the groove (defaults to the theme track) |
height | bar thickness, default 8; the corner radius follows it |
<ProgressBar value={0.4} style={{ width: 200 }} />
<ProgressBar indeterminate style={{ width: 200 }} />
The determinate fill is expressed as flex ratios rather than a percentage width. A percentage child resolves against space that is still being measured, which fed back into the track's intrinsic width — a card with a fuller bar came out wider than one with an empty bar.
indeterminate is the worked example of a
style loop: the block is absolutely positioned and
travels on the logical start edge from -40% to 100% of the track, so it
mirrors with the text direction, is right at any width without measuring
anything, and repaints the bar rather than the window. It reports
aria-busy with no value at all, which is what tells a screen reader
that a percentage is not coming.
Under reduced motion the loop never starts — that part is core's, and every loop in every app gets it — and the block parks in the middle of the track instead of at its off-screen starting point. A still bar that shows nothing would say the app had stopped; a full one would say it had finished.
Select
A dropdown whose menu is a real override-redirect <popup> window anchored
below the trigger.
import { Select } from 'react-x11';
<Select
style={{ width: 160 }}
value={color}
options={[
{ value: '#2980b9', label: 'Blue' },
{ value: '#c0392b', label: 'Red' },
'green', // shorthand: value === label
]}
onChange={(ev) => setColor(ev.value)}
placeholder="Pick a color…"
/>;
| prop | |
|---|---|
options | array of {value, label} or plain values |
value, onChange(ev) | selection |
name | field name, for form libraries |
placeholder | trigger text when nothing is selected |
style + any box props | forwarded to the trigger box |
Behavior: the menu opens on the press — Space and Enter toggle it too; Escape, focus loss, or picking closes it; the option list scrolls when taller than 220px; the trigger participates in Tab traversal.
The menu is the same surface a menu is: an ARGB popup rounded at
radiusPopup with a hairline border where the display composites, and the
active option is the same pill at radiusPopupItem, inset from the sheet's
edge. A dropdown and a menu are one kind of thing, and two shapes for it
would only say that the widgets were written at different times.
Opening on the press rather than the release is deliberate, and it is the one control whose answer to a press is more than a colour: a dropdown exists to be looked at, so waiting for the button to come back up before showing it wastes the whole time the button is down. It is what every desktop toolkit does. A press while the menu is up dismisses it through the popup's pointer grab, so the two never fight over the toggle.
Keyboard, while the trigger is focused (the popup is override-redirect and never takes focus, so the trigger keeps handling keys with the menu open):
| key | closed | open |
|---|---|---|
Down / Up | opens the menu | move the active option, wrapping around |
Home / End | — | first / last option |
Enter / Space | opens the menu | pick the active option |
Escape | — | close without picking |
The menu opens with the current value active, hovering an option makes it active (pointer and keyboard share one highlight), and the active option is scrolled into view.
PageUp/PageDown move by a menu viewport (MAX_MENU_HEIGHT / ITEM_HEIGHT
options), clamping at the ends rather than wrapping the way the arrows do.
Losing the window closes it too. That is not the same event as the trigger's own blur: a window losing the window manager's focus leaves the node inside it focused and merely stops it looking active — so without this the menu would still be up over an application you have switched away from, holding the pointer grab it opened with. The menus do the same.
Type-ahead. Typing letters jumps to the matching option: with the menu
open it moves the highlight, and with it closed it changes the value
outright, the way a native select does. Keystrokes within 700ms accumulate
into one query (b,l finds blueberry, not banana); repeating a
single letter cycles through the options starting with it.
Theming
Select has no provider of its own — it reads the palette from
ThemeProvider like every other widget. The trigger is
background/text in a border box that turns borderFocus on focus or
while open, the chevron and the placeholder text are textMuted, and the menu
highlight is hoverBackground/hoverText.
Calendar / DatePicker
Moved to @react-x11/components. A date picker is a locale
problem before it is a widget, and the calendar systems, holiday sets and
range semantics people need do not belong in a renderer.
PasswordInput
A masked field whose mask is a scribble, not a row of bullets.
<PasswordInput
value={secret}
onChange={(ev) => setSecret(ev.value)}
onSubmit={() => signIn()}
/>
| prop | |
|---|---|
value / defaultValue | the secret; controlled with onChange |
onChange(ev) | ev.value is the new value |
onSubmit(value) | Enter |
placeholder | shown while empty ('Password') |
revealable | show the eye at all, default true |
revealed / onRevealChange | drive the reveal yourself |
maxLength | in code points |
drawMask(ctx, info) | draw the mask yourself |
disabled | not focusable, not editable |
Why a scribble
Bullets answer the wrong question. They report how many characters have been typed — countably, from across the room — and they report almost nothing about the keystroke that just landed, because one more identical dot at the end of a row of identical dots is the least visible change a field could make. The feedback is weakest exactly where a password field needs it, and strongest exactly where it should not be.
So the mask is a single stroke through points chosen by a generator seeded from the window id and a hash of the value. Every keystroke reseeds it, so the whole curve moves on every character — feedback you cannot miss — and nothing in the shape is per-character: the pen visits a number of points taken from the mask's width, where a character is worth about half a point, so no part of the stroke can be matched to anything typed.
It is a scribble rather than a waveform, and that is a decision rather
than a look. A stroke whose x only ever increases is the plot of a
function, and the eye reads it as one — value against position, meaning in
the peaks — however wild the y is. So the points are laid out one per
column, which is what makes the stroke cover the width it was given, and then
visited out of order: the pen doubles back, crosses what it has already
drawn, and leaves loops. The shuffle is local rather than global, because in
a box seventeen pixels tall a jump right across it is a long shallow scratch,
and a maskful of those is not a scribble either.
What does grow is the width, because a mask that did not would say nothing about progress. Each position contributes an advance drawn from a second, window-seeded stream, so the width only ever grows as you type, never twitches when a character is replaced, and is not a clean multiple of anything a glance could divide.
The honest limit, since a mask that oversells itself is worse than one that does not: this hides a glance, not a recording. Someone watching the field grow keystroke by keystroke still counts the keystrokes, and a long password still sits in a visibly different bracket from a short one.
Two states, and what holds across both
Masked, editing is smaller than <textinput>'s, because a scribble has
nowhere to put a caret: type, Backspace, Ctrl+Backspace / Ctrl+U / Delete to
clear, Ctrl+V or Shift+Insert to paste, Enter to submit. No caret, no
selection, no undo history — a rewindable secret is not a feature.
Revealed, it is an ordinary text input, because that is what it looks
like and anything else would be a trap. A real <textinput> takes the mask's
place: caret, selection, arrow keys, a click into the middle of the word,
undo, the edit menu. Focus follows the swap in both directions, and the
reveal ends when the keyboard leaves the widget — not when it moves between
the field and the input inside it.
What holds in both states is that nothing leaves by a selection. Ctrl+C
and Ctrl+X do nothing, the revealed input carries
sensitive so its menu has no Cut or Copy, and
neither state ever takes PRIMARY, so a middle click in another window cannot
spend it. The line is between what is on screen and what is on the clipboard:
the first stops being visible when the field is hidden, the second is
readable by every client on the display until something else takes it.
While the value is masked it is never laid out and never drawn: the mask
is measured from one reference character, so the secret does not enter ntk's
shaping cache and its glyphs never reach the X server. Revealing it costs
what revealing it costs. The eye is a pointer affordance and not a tab stop,
as GTK's peek icon is; revealed + onRevealChange put the toggle wherever
your keyboard can reach it.
Caps Lock is reported while it is on, from the modifier state on the keys as they arrive — the mistake that a masked field otherwise lets you make four times before telling you.
Drawing your own mask
drawMask(ctx, info) replaces the scribble entirely; info is
{ width, height, seed, color, length }, where width is the mask width the
field worked out and seed is the value-and-window seed. The scribble itself
is strokeScribble in src/components/scribble.js, which is worth reading
before replacing it — the reasoning for each number is there.
<PasswordInput
drawMask={(ctx, { width, height, color }) => {
ctx.fillStyle = color;
ctx.fillRect(0, height / 2 - 1, width, 2);
}}
/>
Password managers
Paste and typing are the two seams that reach a field on this desktop, and both work here: XTEST auto-type arrives as ordinary key events, and Ctrl+V takes a secret from the clipboard with control characters stripped so a manager's trailing newline stays out of it. Which manager does what, what the window title has to do with it, and the seams that are not a field — Secret Service, the sandbox portal, AT-SPI — are in desktop.md.
Slider
A draggable value control.
import { Slider } from 'react-x11';
<Slider
value={volume}
min={0}
max={100}
step={5}
style={{ width: 200 }}
onChange={(ev) => setVolume(ev.value)}
/>;
| prop | |
|---|---|
value, onChange(ev) | current value (controlled) |
name | field name, for form libraries |
min, max, step | range and quantisation (defaults 0, 100, 1) |
height | the bar thickness (default 4); width comes from style |
disabled | inert, dimmed |
Dragging uses pointer capture: the press captures, so the thumb keeps following a pointer that has wandered far outside the widget, and releasing out there still ends the drag.
Keyboard: arrows step, Home/End jump to the ends, PageUp/PageDown
move by ten steps.
The thumb is centred on its value, so the usable travel is the track width
minus one thumb width — otherwise min and max would be unreachable.
Tooltip
A hover hint in a <popup>, so it can extend past the owner window.
import { Tooltip } from 'react-x11';
<Tooltip label="Back to zero">
<Button onPress={reset}>Reset</Button>
</Tooltip>;
| prop | |
|---|---|
label | the hint: a string, or an element (nothing shows without it) |
direction | 'auto' (default), 'top', 'bottom', 'left', 'right' |
delay | ms of hover before showing (default 500) |
fontSize | label size, also used to size the popup |
width, height | pin an axis of the bubble exactly; anything not pinned is measured |
maxWidth, maxHeight | cap what an element label measures out to, inside the screen's own cap |
Wraps its children in a row box carrying the hover handlers and the anchor ref, so it composes around any element. Hides immediately on leave and on mousedown — a tooltip lingering over the menu you just opened is the classic annoyance.
One at a time, per connection: a hint belongs to where the pointer is, and there is one pointer. A trigger taking the hover dismisses whatever is showing straight away rather than at the end of its own delay, so two are never up together saying different things about the same place. This does not fight the safe-polygon grace that lets a hint with content in it be reached — a trigger underneath an open hint cannot be hovered, the popup being a window above it.
direction is which side of the trigger it opens on. The default, 'auto',
takes the first side the hint fits on, preferring above — measured against
the screen, because a popup is a real X window and the screen is what
bounds it. A named side is a preference rather than a promise either way:
it still flips to its opposite instead of opening off-screen. (placement
is the older name for the same prop and still wins where it is given.)
A string label is measured and the popup sized around it, because a
<popup> is a real X window and needs its size before layout. An
element label is measured too — nothing to size, nothing to guess: the
popup is rendered once hidden
at its natural size (a real layout of the real content, capped at the screen
and at maxWidth/maxHeight), the size is read back, and the same
placement math runs before anything is mapped. Both commits land in the same
task and the popup maps last, so it is only ever on screen placed and at its
final size. The element gets the bubble to fill, with no padding imposed on
it:
<Tooltip
label={
<box style={{ flexGrow: 1, padding: 10, gap: 7 }}>
<text style={{ color: '#f5f6fa' }}>Scratch volume</text>
<ProgressBar value={used} />
</box>
}
maxWidth={260}
direction="right"
>
<box>…</box>
</Tooltip>
width/height still pin an axis exactly: give both and the measuring pass
is skipped entirely (the guaranteed-fit mode element labels used to
require); give one and the other is measured for it, so width={340} is a
fixed column whose height fits the message. The size is settled at open —
content that changes size while the hint is up keeps the bubble it opened
with until the next open, which is a hint's lifetime away.
Anything renders in there, widgets included — it is a real tree in a real
window, not a rich-text label. examples/tooltips.jsx has both kinds.
The palette inside the bubble is upside down. A tooltip is drawn in the
palette's ink so that it reads as a label over the desktop rather than as
another panel of the app — so inside it, $background is the bubble and
$text is the ink on it, published through a ThemeProvider so that
useTheme() agrees. Write the content the way you would write anything
else and it is legible in both schemes; hard-code a light text colour and
it will be invisible on the light bubble a dark palette gives it. textMuted
is derived for the same reason — the palette's own is a grey chosen against
the app's background, not against this one — and $surface is the bubble
too, so a card written for the app does not light up inside a tooltip.
Where the display composites, the popup is an ARGB window: a bubble rounded
at radiusTooltip, no border, and a small arrow pointing back at the middle
of the trigger with everything neither covers left transparent. Without a
compositor it is the square opaque rectangle it has always been and there is
no arrow — those pixels would be black rather than empty.
Dialog
A dialog in a <popup>, centred over the owner window — and one the
window manager knows is a dialog: it is a managed window with
WM_TRANSIENT_FOR pointing at its owner, so it is framed, movable, closable
through the WM, out of the taskbar and alt-tab list, and (on a full EWMH
window manager) stacked above its owner and iconified with it.
import { Button, Dialog } from 'react-x11';
<Dialog
open={confirming}
title="Clear the form?"
onClose={() => setConfirming(false)}
actions={
<>
<Button label="Cancel" onPress={() => setConfirming(false)} />
<Button primary autoFocus label="Clear" onPress={clear} />
</>
}
>
The name and the greeting below it will be discarded.
</Dialog>;
| prop | |
|---|---|
open | renders nothing when false; the popup exists only while true |
title | names the dialog, drawn once: the frame's caption when managed, a bold in-content heading when not |
children | body content; strings become <text> |
actions | elements for the right-aligned button row |
onClose | Escape, or the window manager's close button |
managed | false for the override-redirect popup 1.x shipped (below) |
width, height | popup size (default 360×170) |
The focus behaviour is the renderer's, not the component's: trapFocus
keeps Tab inside the dialog, stops presses elsewhere from moving focus, and
hands focus back to whatever had it — usually the button that opened the
dialog — when it closes. See
Focus scopes. Put autoFocus on a control
inside to pick the first stop; with nothing to focus, the dialog surface
takes focus itself (tabIndex={-1}) so Escape and Tab work immediately.
Escape closes because keys go to the focused node inside the popup and
bubble out through the popup's place in the JSX tree; so does the window
manager's close button, through onCloseRequest. Pointer modality is not
enforced — widgets in the owner window stay clickable behind the dialog —
so it is for confirmations, not for guarding state. (_NET_WM_STATE_MODAL
is the mechanism that would enforce it, and it means nothing without the
WM_TRANSIENT_FOR this now sets; that is why the property had to come
first.)
managed
managed | what you get |
|---|---|
true (default) | a WM-managed window: frame, titlebar, movable, WM close button, transient for its owner, out of the taskbar. A press outside does not close it. |
false | the override-redirect popup: no frame, not movable, a pointer grab, and a press anywhere outside calls onClose. |
The title follows the frame: managed, it is the frame's caption and the
content starts with the body; unmanaged there is no frame, so the title is
drawn as a bold heading at the top of the content instead. It is never drawn
twice, and it names the dialog for accessibility either way.
They are one choice, not two: a client-side pointer grab over a window the
window manager is trying to let the user drag swallows the press that would
start the drag, so grab and the frame turn on and off together. A managed
dialog staying open when you click elsewhere is not a regression — it is what
a dialog does. managed={false} is the right shape for a transient
confirmation on a display with no window manager at all.
The owner is resolved automatically: Dialog already keeps an out-of-flow
<box> inside the owner window for placement, and a ref to any drawn node
resolves to the window that owns it
(transientFor).
A <popup> is a real X window and needs its size up front, hence explicit
width/height rather than sizing to content. Placement comes from
centerRect(node, {width, height}), exported alongside anchorRect for
window-centred popups of your own.
MenuBar / ContextMenu
Pull-down and right-click menus, both rendered in <popup> windows so they
escape the owner window, both anchored with useAnchor.
import { MenuBar, ContextMenu } from 'react-x11';
<MenuBar
menus={[
{
label: 'File',
items: [
{ label: 'New', shortcut: [['Control', 'N']], onSelect: newFile },
{ type: 'separator' },
{ label: 'Save As…', enabled: false },
{
label: 'Wrap lines',
toggleType: 'checkmark',
toggleState: wrap ? 1 : 0,
onSelect: toggleWrap,
},
],
},
]}
/>;
<ContextMenu items={items} style={{ flexGrow: 1 }}>
<text>Right-click me</text>
</ContextMenu>;
Item shape: { label, onSelect, type, items, enabled, visible, shortcut, toggleType, toggleState, icon, iconName, disposition, key }. Both take an
onSelect(item) prop as well, fired after the item's own. A bar menu opens on
the press, for the reason Select does.
shortcut is a binding, not a label. It is a list of alternatives, each a
list of modifier tokens ending in the key — [['Control', 'S']] — and a
mounted menu answers it: Ctrl+S fires that item's onSelect without the menu
being opened, gated by the item's own enabled and visible, and it keeps
working when the desktop's panel has taken the menu over. One array is then
the drawn menu, the exported dbusmenu payload, the aria-keyshortcuts
announcement and the binding, so there is nothing to keep in step by hand. The
chord rules, the vocabulary the key tokens come from, useAccelerator for a
shortcut that is not in a menu, and accelerators={false} for an app with its
own dispatcher are all in events.md.
The vocabulary is com.canonical.dbusmenu's, and that is not incidental.
On a desktop whose panel shows application menus, MenuBar hands this array
straight over and stops drawing — no configuration, no second authoring model,
the same line of JSX either way. type: 'separator' rather than
separator: true, enabled: false rather than disabled: true,
toggleType/toggleState rather than a bare checked, and shortcut as a
list of modifier tokens rather than a display string, all follow from that.
toggleState is 0, 1 or -1, the last being indeterminate. See
globalmenu.md, which also covers globalMenu={false}.
The sheet. Where the display composites, a menu is an ARGB window
rounded at radiusPopup with a hairline border, and the highlight on a row
is a pill: inset from the popup's edge by the list's own padding and rounded
a step tighter (radiusPopupItem), so it reads as sitting on the sheet
rather than cutting across it. The fill is hoverBackground — the palette's
selection colour, shared with Select's active option and Table's current
row, so one token moves every highlight in the app. Without a compositor
both go square, which is what those pixels can honestly be.
One selection at a time. The bar item, the row under it and the row
under that are one trail, and only its deepest level wears the selection
colour; everything behind is drawn in surfaceActive.
They are still chosen — they are the way back — but two selection-coloured
rows in two menus claim the same thing twice, and only one of them is where
the keys are going. A submenu that is open with nothing selected in it has
not taken over yet: the pointer is still on the row that opened it, so that
row stays lit — which is why a menu bar item stays coloured until you touch
a row in the menu it opened, and goes quiet then.
The bar item wears the same pill as the rows, at radiusPopupItem — the same
padding around the same capitals, so a title and the first row of the menu it
opens are one shape in one size, inset into the bar by a few pixels of margin
that sit outside that padding.
Titles that do not fit. A bar narrower than its menus paints as many as
fit and moves the rest behind a chevron at the end of it — the system set's
moreVertical, drawn rather than a », so it is a mark and not a font's
opinion of one. The chevron is an ordinary bar entry whose items are the
menus that were cut, so each row opens the menu it stands for as a submenu,
and it is a real stop for Left/Right: the keyboard walks exactly what is
painted, never a title laid out past the window's edge. Widen the window
and the titles come back one at a time. The bar measures itself, so the
first frame of a new bar always shows everything and the cut lands on the
next — and none of this happens on a desktop whose panel has taken the menu,
where there is no bar of ours to overflow.
Icons. icon fills the 16px column left of the label — the same column
a toggleType mark uses, so an item that is both checked and iconned shows
the check: the check is state, the icon is only identity. icon is drawn by
react-x11 and never crosses the bus; iconName is the icon-theme name a
panel uses (globalmenu.md). One column rather than
two, because a second would indent every label in the menu to reserve room
for icons most items do not have.
Pass a function for anything real. It is called with the colour the row's label is being drawn in and the size the column allows, which is what lets one drawing follow its row into the highlight and into the disabled grey:
const save = ({ color, size }) => (
<svg source={SAVE_SVG} style={{ width: size, height: size, color }} />
);
{ label: 'Save', icon: save, shortcut: [['Control', 'S']], onSelect: onSave }
Paint the SVG in currentColor and the renderer caches it as coverage
rather than as pixels, so recolouring per row is a composite and every
colour of the icon shares one rendered copy
(elements.md). A system icon is already
that, and takes the same two arguments:
{ label: 'Close', icon: (p) => <Icon name="close" {...p} />, onSelect: onClose }
A string icon is drawn as text, which is a one-liner — but it is only as
good as the font, and ✂ or ⏻ is an empty box on a machine without them.
An element is rendered as-is. examples/menu.jsx has a worked set.
The toggle marks are system icons for that reason: a checked item is a
check, a radio one a dot, and the indeterminate third state a dash.
They used to be ✓, ● and – set in the row's font, which is the same
gamble this paragraph tells you not to take.
Submenus. Give an item its own items and it becomes a submenu parent,
marked with a chevronRight and opening to the side:
{ label: 'Export', items: [
{ label: 'PNG', onSelect: exportPng },
{ label: 'SVG', onSelect: exportSvg },
] }
Nesting is unlimited. Each level is its own <popup> with placement: 'right', so it flips to the left near a screen edge like any other anchored
popup. It hangs off the menu's outer edge and lines up with the row that
opened it — two different nodes, which is what anchorRect's alignTo is
for. Anchoring both to the row would put the submenu inside its parent by
the menu's border and padding, and the two would overlap.
Lined up on the items, not on the boxes: a submenu whose top edge is
level with its parent row starts its first item a border and a padding
lower, and what the eye lines up is the text. anchorRect's alignOffset
is the shift that pays for the submenu's own chrome, so the row you came
from and the row you arrive at are on one line.
Keyboard. Up/Down move the active item, skipping separators and
disabled entries and wrapping; Home/End jump to the ends; Right opens a
submenu (selecting its first item) and Left leaves one; Enter/Space
activate — or open a submenu, for a parent row; Escape closes one level
at a time. In a MenuBar, Left/Right walk between menus when there is
no submenu to move through, and with one menu open, hovering another
switches to it. Hovering a submenu parent opens it with nothing selected
inside.
PageUp/PageDown step ten rows and then settle on the nearest selectable entry in the direction of travel, so a page never lands on a separator or a disabled row. Menus size to their content rather than scrolling, so the stride is fixed — deriving one from the menu height would just equal Home/End.
Type-ahead. Typing letters jumps to the entry whose label starts with
them, in whichever level is deepest open. Keystrokes within 700ms
accumulate into one query, so c,a finds Carrot rather than jumping to
Apple first; repeating a single letter cycles through the entries
starting with it. Disabled entries and separators are never matched.
Open state is a single path of active indices — one per open level — so moving the selection at any level truncates the path and closes deeper levels for free.
Both keep focus on a node in the owner window — the popup is
override-redirect and never takes focus — which is the same arrangement
Select uses. ContextMenu's wrapper is focusable for that reason and
takes focus when the menu opens.
Switching between MenuBar menus reuses the same X window and moves it
rather than destroying and recreating one, so there is no flicker.
Safe-polygon hover
Reaching a submenu means moving the pointer diagonally across the rows in
between, and reaching a tooltip means leaving the trigger it belongs to — so
naive hover handling closes both just as the user aims at them. MenuBar,
ContextMenu and Tooltip therefore use
floating-ui's safePolygon
idea: the triangle between where the pointer was and the near edge of the
open surface counts as still hovering the parent.
While the pointer is inside that triangle, hover changes are held back —
but only for SAFE_HOVER_DELAY (320 ms), so a pointer that stops there
still means what it landed on. Leaving the triangle switches immediately,
and reaching the surface keeps it open for as long as the pointer stays.
The helpers are exported for widgets of your own:
movingToward(point, apex, rect), safePolygon(apex, rect, buffer),
pointInPolygon(point, polygon) and screenPoint(ev). All coordinates are
screen coordinates, because the trigger and the popup are different X
windows.
Tabs
One panel visible at a time, switched by a strip of tabs.
<Tabs
items={[
{ id: 'general', label: 'General', content: <GeneralPage /> },
{ id: 'advanced', label: 'Advanced', content: () => <AdvancedPage /> },
{ id: 'legacy', label: 'Legacy', disabled: true },
]}
/>
| prop | |
|---|---|
items | { id, label, content, disabled }[] |
value / defaultValue | selected id — controlled with value + onChange |
onChange(id) | a tab was chosen |
orientation | 'horizontal' (default) or 'vertical' |
manual | arrows move focus only; Enter or Space commits |
content may be a node or a function. A function is called only while that
tab is selected, which is how to avoid building a panel nobody is looking
at. Items with no content at all make Tabs a pure navigator — useful
when the panel lives elsewhere, as in examples/app.jsx, where the strip is
in one half of a SplitPane and the panel in the other.
The strip is a single tab stop. Left/Right (Up/Down when vertical) move
and wrap, Home/End jump to the ends, disabled tabs are skipped. Arrows
select as they move, the way a desktop notebook behaves; manual splits
focus from selection, which is what you want when a panel is expensive.
Table
A grid with a header that stays put, resizable columns, and only the rows in view actually built.
<Table
columns={[
{ id: 'name', label: 'Name', width: 220 },
{ id: 'size', label: 'Size', width: 90, align: 'right' },
]}
rows={files}
onSelect={(id, row) => open(row)}
/>
| prop | |
|---|---|
columns | { id, label, width, align, value, render }[] |
rows | { id, … }[] — id identifies the row |
rowHeight | every row is this tall (24 by default) |
sort / defaultSort | { column, direction }; reported by onSortChange |
selected / defaultSelected | selected row id; reported by onSelect(id, row) |
onActivate(id, row) | a double click, or Enter on the selection |
onColumnResize(id, width) | after a header drag |
value(row) feeds sorting and the default cell text; render(row, { selected, column }) replaces the cell contents entirely.
A render is told when its row is selected, because the selection is a
filled bar and a colour picked against the resting background disappears
into it — a directory in the accent, a failure in red. Fall back to
hoverText there and let the glyph carry the meaning:
render: (row, { selected }) => (
<text style={{ color: selected ? '$hoverText' : statusColour(row) }}>
{row.state}
</text>
);
Rows must all be rowHeight tall. That is the price of the table only
building what is on screen: with ten thousand rows it mounts the twenty or
so in the viewport and swaps them as you scroll, and everything above and
below is a single spacer box, so the scrollbar still measures the whole
list. Sorting a hundred thousand rows is still the caller's problem — pass
sort and sort the data yourself when that matters.
The table holds the focus, not the row: a row is unmounted as soon as it
scrolls out of view, and focus would go with it. Up/Down move the selection,
PageUp/PageDown by a viewport, Home/End to the ends, and the selection is
kept on screen without building the rows in between. A click selects, a
double click activates — the same onActivate Enter fires, counted from
ev.detail like any other multi-click.
The header scrolls sideways with the body but never vertically. Between two headers there is a hairline rule and, just left of it, a grab band seven pixels wide: the separator you see and the handle you hit are not the same size, because a boundary wants to be thin and a handle wants to be easy. The band is invisible until the pointer is on it and takes the accent while it is held, and it is a sibling of the header rather than a child of it — a click fires on the nearest common ancestor of press and release, so a handle inside the header would end every resize with a sort of the column that was just dragged. The handle takes focus on the press, so Left/Right resize by 16px from the keyboard as well.
Tree
Moved to @react-x11/components: a tree wants virtualisation,
drag-to-reorder and lazy children, none of which core has a reason to carry.
SplitPane
Two panes with a divider you can drag.
<SplitPane direction="row" defaultSize={220} min={120} minSecond={300}>
<Sidebar />
<Editor />
</SplitPane>
| prop | |
|---|---|
direction | 'row' (default) or 'column' |
size / defaultSize | the first pane's width or height, in pixels |
onResize(size) | after a drag or a key step |
min / minSecond | how small either pane may get |
Only the first pane's size is stored; the second takes what is left, so a window resize can never leave a gap. The drag clamps against the container as it is laid out at that moment, so the limits stay honest when the window changes underneath it.
The divider is focusable: arrows move it by 16px, Home/End drive it to either limit. Dragging captures the pointer, so it keeps tracking after the pointer leaves the six pixels it started on, and it keeps the grip where it was taken rather than jumping to the pointer.
Canvas3D
Moved to @react-x11/components/three as <Canvas>, with
the scene graph it wrapped. Core keeps the <glarea> surface underneath it
— see gl.md and examples/viewer3d.jsx.
useAnchor(ref) / anchorRect(node, options)
The placement math behind Select and Tooltip, exported for building
your own popup-based widgets.
const ref = useRef(null);
const measure = useAnchor(ref);
// screen coordinates for a <popup>, given the size you intend to use
const rect = measure({ placement: 'bottom', align: 'center', width, height });
// -> { x, y, width, height, placement }
| option | |
|---|---|
placement | 'bottom' (default), 'top', 'start', 'end', 'left', 'right' |
align | 'start' (default), 'center', 'end' on the cross axis |
offset | gap from the anchor in px (default 2) |
at | a rect inside the node to anchor to instead — a caret, a cell |
width, height | size of the popup you are positioning |
alignTo | node the alignment reads, when it is not the anchor |
placement is a preference, not a promise: a menu near the bottom of
the screen flips above its trigger rather than opening off-screen, and the
result is clamped into the screen either way. The side actually used comes
back as placement. Where screen geometry is unavailable it places without
clamping.
'start' and 'end' are the logical sides and are what a submenu
wants: it opens away from the edge its parent's rows begin at, which is
leftwards in a mirrored menu, and still flips at the screen edge from there.
'left'/'right' stay physical. align mirrors the same way, but only
when the popup is above or below its trigger — with one beside it the
alignment axis is vertical, and nothing vertical mirrors. The direction is
read off the anchoring node, so a menu inside a mirrored panel needs no
argument.
alignTo takes the two axes from different nodes: the placement edge
from the anchor, the alignment from alignTo. A submenu is the case that
needs it — it belongs against the outer edge of the menu it comes out of,
but level with the row that opened it, and that row is inset by the menu's
border and padding. Both nodes have to be in the same window.
at — anchoring to part of a node
at is a rect in the anchor node's own coordinates, and it becomes the
anchor: the side that flips, the edge that aligns, the gap offset leaves,
the rect tracking keeps in view. A caret is the case it exists for — a
moving point inside one element — and a table cell, a chart datapoint or a
highlighted span are the same shape.
const measure = useAnchor(editorRef);
const rect = measure({
at: { x: caretX, y: lineTop, width: 1, height: lineHeight },
placement: 'bottom',
width: 240,
height: rows * 22,
});
width and height are optional ({x, y} alone is a point), and width
on the popup then defaults to the sub-rect's rather than the node's.
Node-relative rather than screen-relative on purpose: the offset stays true
through everything that moves the node, so useAnchorTracking follows a
caret with no extra work — and its out-of-view test becomes the caret's,
which is what you want, since an editor's own lines scroll away long before
the editor does.
A popup that has to measure itself first anchors from the other side.
Rows sized to their labels, a menu as wide as its widest item: the size is
not known until the popup's content is laid out, and by then React has
already rendered. <popup anchor={{ to: ref, at }}> hands the placement to
the popup, which does it in the one place the size is known —
elements.md.
anchorArea(node)
The rect a popup anchored to node may be placed in: the usable part of the
monitor that node is on — per-monitor, _NET_WORKAREA taken off it, the
same answer <window width="auto"> is capped
by. null where there is no display to ask.
What needs it is a surface sizing itself: a menu measured to its longest
label is never usefully wider than the screen it opens on, and anchorRect
can slide a popup back from an edge but nothing rescues one that does not
fit. Select and ContextMenu both cap themselves this way.
useDropTarget(options) / useDragSource(options)
The render-state layer over the drag-and-drop props, for the cases a
':drag-over' or ':dragging' style block cannot cover — a hint label, a
disabled sibling, a drag preview that follows the pointer.
const { dropProps, isOver, isAccepted } = useDropTarget({
accept: ['files'],
onDrop: (e) => setFiles(e.files),
});
const { dragProps, isDragging, position } = useDragSource({
data: { 'text/plain': label },
actions: ['copy', 'move'],
});
Spread dropProps / dragProps on any drawn element. Both are thin — the
props they return are the same ones you can write by hand — so reach for
them when the render changes, and for the plain props when it does not.
position is { x, y, accepted } in screen coordinates while a drag is in
flight, which is what a <popup dragPreview> follows. Full reference:
drag-and-drop.md.
The other components — Button, Checkbox, Radio/RadioGroup,
Switch, ProgressBar — are demoed together in examples/widgets.jsx.
The Select source (src/components/Select.js) is the reference for building
your own: hover/focus state with useState, a <popup> for anything that
must escape the window bounds, and a ref to the trigger node for anchoring
(node.abs + node.root.window.x/y).
The window's screen position comes from the server (TranslateCoordinates),
refreshed when the window is realized and whenever it is configured — not
from window.x/y. Once a reparenting window manager has put the window
inside its frame, those are relative to the frame, and a popup placed with
them lands near the corner of the screen instead of under its trigger.