GPUI Elements

GPUI components use native intrinsic elements for layout, controls, images, icons, and floating layers.

ElementPurposeChildren
divA container for layout.Yes
buttonA native button with default styling.Yes
inputA native single-line text editor.No
textareaA native multi-line text editor.No
imgA bitmap or full-color image.No
svgA single-color vector icon.No
anchoredA floating layer for menus, tooltips, and more.Yes

Text is not an element. It is written as ordinary JSX content. Adjacent text nodes, including interpolations such as Count: {count}, render as one inline run that wraps with its container.

Shared Props

Every element accepts these props:

PropDescription
styleAn author style object, or a Cell holding one. See GPUI Styling.
tabIndexThe native tab order. Negative values remain focusable from code.
refA Cell or callback that receives the element. See Refs and Imperative Methods.
onClick…Event handlers. See GPUI Events.

Any prop can be a Cell, so values can change reactively.

div

A div has no default styling. Layout is expressed through style:

<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 16 }}>
  <div>First</div>
  <div>Second</div>
</div>

button

A button is a native control. By default it centers its children in a padded, rounded, neutral surface with a hairline border. It activates on click, or on Enter and Space when focused, and takes part in Tab traversal.

const count = Cell.source(0);

return (
  <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
    <div>Clicked {count} times</div>
    <button onClick={() => count.set(count.get() + 1)}>Increment</button>
  </div>
);

Setting disabled skips the button in Tab traversal, ignores pointer and keyboard activation, and renders it dimmed.

input and textarea

input is a native single-line editor and textarea is a native multi-line editor with wrapping.

PropElementsDescription
valuebothThe text content.
placeholderbothText shown while the control is empty.
minRowstextareaMinimum visible rows while the control auto-sizes.
maxRowstextareaMaximum visible rows while the control auto-sizes.

Both fire input and change events. The event's value property holds the current text:

const name = Cell.source('');

return (
  <input
    value={name}
    placeholder="Your name"
    onInput={(event) => name.set(event.value)}
  />
);

The controls come with default styling: comfortable padding, a 6px corner radius, a white surface, and a hairline border. Properties in the style override these defaults one at a time, so a form field needs no surface styling of its own:

<input
  placeholder="Accent"
  style={{
    backgroundColor: '#eef2ff',
    borderColor: '#6366f1',
    focused: { borderColor: '#e11d48' },
  }}
/>

img

PropDescription
srcAn HTTP(S) URL, a file: URL, a root-relative path, or an imported asset.
objectFitfill, contain, cover, scaleDown, or none.
altAccessible text announced for the image.

An img fires load and error events.

An image is given a size through style:

import logo from './logo.png';

<img src={logo} alt="Logo" style={{ width: 64, height: 64 }} />;

Root-relative paths such as /assets/logo.png resolve against the Vite root during development and against the app's resources directory in a packaged app. Remote, data:, and file: sources are used as they are.

svg

An svg draws a single-color vector icon in the current text color, like currentColor in CSS. An icon follows the color of its container, including hover and active styles, unless it sets its own color.

The markup is passed as content, for example from a ?raw import, or src points at a local file. When both are set, content wins.

import check from './icons/check.svg?raw';

<button style={{ color: '#555555', hover: { color: '#000000' } }}>
  <svg content={check} alt="" />
  Save
</button>;

Icons are 16 × 16 by default. width and height in style resize them. alt provides the accessible label.

img is used for full-color SVG artwork. Icon markup passes through content or src rather than JSX children.

anchored

An anchored element is a floating layer. It is the primitive behind menus, tooltips, and popovers. Placement is delegated to GPUI instead of being measured in JavaScript.

PropDescription
sidetop, right, bottom, or left. The side of the parent slot to place the content on.
alignstart, center, or end. Alignment along that side.
gapDistance from the chosen side, in logical pixels.
offsetAn { x, y } adjustment applied after side positioning.
positionAn { x, y } point in window coordinates. Use it instead of side and align to place the layer exactly.
fitsnap (the default) keeps the layer inside the window. switch flips the anchor instead.
snapMarginThe window-edge margin used by fit="snap".
deferredPaints the layer above normal content. On by default.
priorityOrdering between deferred layers.
occludeWhether the layer blocks pointer input to what is behind it. On by default.
function Menu() {
  const isOpen = Cell.source(false);
  const toggle = () => isOpen.set(!isOpen.get());
  const close = () => isOpen.set(false);

  return (
    <div>
      <button onClick={toggle}>Menu</button>
      {If(isOpen, () => (
        <anchored side="bottom" align="start" gap={4}>
          <div
            onMouseDownOutside={close}
            style={{ padding: 8, backgroundColor: 'white', borderRadius: 6 }}
          >
            Menu content
          </div>
        </anchored>
      ))}
    </div>
  );
}

mousedownoutside fires when the pointer is pressed anywhere outside the element, which makes it the natural way to dismiss a floating layer.

An anchored child stays margin-free. gap and offset handle spacing, or the child is placed inside a wrapper when layout margin is needed.

Refs and Imperative Methods

A Cell passed to ref receives the element. Elements expose methods for focus, scrolling, and measuring:

import { Cell } from 'retend';
import type { GpuiInputElement } from 'retend-gpui';

function SearchBox() {
  const input = Cell.source<GpuiInputElement | null>(null);
  const focusInput = () => input.get()?.focus();

  return (
    <div style={{ display: 'flex', gap: 8 }}>
      <input ref={input} placeholder="Search" />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

Every element has these methods:

MethodDescription
focus()Requests native keyboard focus.
blur()Releases focus if this element owns it.
scrollTo(x, y)Scrolls to an absolute offset.
scrollBy(x, y)Scrolls relative to the current offset.
scrollIntoView()Scrolls the nearest scroll containers enough to reveal the element.
getScrollOffset()Returns a promise of the current { x, y } scroll offset.
measure()Returns a promise of the painted bounds. See below.

input and textarea add three more:

MethodDescription
select()Selects all of the text.
setSelectionRange(start, end)Sets the selection, using UTF-16 offsets.
getSelection()Returns a promise of the current { start, end }.

measure() works like getBoundingClientRect(). It resolves to the painted, transformed border box in window coordinates, plus the size of the scrollable content:

const { x, y, width, height, scrollWidth, scrollHeight } = await element.measure();

Calling any of these on an element that has been removed from the tree throws an error.