Composer UI

SkillMedia

Use when building or styling plugin UI with Composer's design system — the

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Composer UI skill

What this skill tells your AI

The instructions your AI receives, as published by dxos/dxos in .agents/skills/composer-ui/SKILL.md and read by ahel’s review.

How to consume Composer's design system (@dxos/react-ui*) from a plugin. This is the UI adjunct to [[composer-plugins]] (which owns plugin structure: capabilities, surfaces, schema, operations) and [[composite-components]] (which owns authoring new @dxos/react-ui primitives). When you're laying out a container, picking a color class, wiring a toolbar, or writing a story, the rules live here.

Golden rule: If the design system already has a primitive, a token, or a layout for what you need you must use it. Reaching for a raw <div> with custom classes, a native <input>, or a guessed color token is almost always a sign you missed an existing piece. Find it (grep an existing themed component) before inventing.

Low-level components (plugin//src/components, react-ui-). Must NOT depend on @dxos/app-framework or @dxos/app-toolkit capabilitiess. Instead aspects that may be derived from capabilites must be passed as properties. Each component lives in its own subdirectory with an index.ts barrel. Use named exports; no default exports.

Package family

Import from the most specific package. Common ones:

PackageProvides
@dxos/react-uiCore primitives: Panel, Card, List, Input, Button, IconButton, Icon, ScrollArea, Toolbar, Dialog, Popover, Tooltip, Select, Tag, Avatar, Separator, plus useTranslation, useThemeContext, DensityProvider.
@dxos/react-ui-themeThe theme (tx resolver, tokens, Tailwind preset). You rarely import from it directly — tokens are plain Tailwind classes.
@dxos/react-ui-formForm.* — schema-driven forms (the way to edit ECHO objects).
@dxos/react-ui-menuMenu.*, MenuBuilder, useMenuActions — toolbars and command menus.
@dxos/react-ui-attentionAttention system: AttentionGlyph, useAttention, attendable wiring.
@dxos/react-ui-listNavigable lists with dx-current/dx-selected item states.
@dxos/react-ui-mosaic / -boardLayout composition (Mosaic Stack, Deck, Board) — usually owned by the shell, not plugins. (@dxos/react-ui-stack is deprecated — use the Mosaic Stack.)
@dxos/react-ui-editor / -markdownText/markdown editing.
@dxos/react-ui-table / -dataData tables.

Many more exist (-card, -chat, -thread, -pickers, -search, -syntax-highlighter, …). When you need a domain widget, check for a react-ui-<domain> package before building one.

Theme tokens

Color/spacing tokens are plain Tailwind classes generated from CSS custom properties. The source of truth is packages/ui/ui-theme/src/css/theme/semantic.css and the per-component files under packages/ui/ui-theme/src/css/components/.

The rule: every --color-<name> custom property yields the utilities bg-<name>, text-<name>, border-<name>. So --color-modal-surfacebg-modal-surface. To find a valid token, grep semantic.css for --color-, or copy classes from an existing themed component — never guess a name. Invented tokens (bg-input, text-primary) aren't in the theme and render wrong (e.g. white-on-white in dark mode), which is the kind of bug that's invisible until someone toggles the theme.

Verified common tokens (kebab-case is current; legacy camelCase like bg-modalSurface / text-baseText is being phased out — prefer the kebab forms):

  • Surfaces: bg-base-surface, bg-card-surface, bg-modal-surface, bg-toolbar-surface, bg-sidebar-surface, bg-deck-surface, bg-group-surface, bg-input-surface, bg-hover-surface, bg-attention-surface, bg-accent-bg (+ -hover).
  • Text: text-base-fg (body), text-description (muted), text-subdued (dimmest), text-placeholder, text-accent-text.
  • Borders: border-separator, border-subdued-separator, border-primary-separator, border-active-separator, border-focus-ring.

Themed primitives accept overrides via a classNames prop (string or array) — never className. Pass functional layout hints (p-4, space-y-4, @container queries) freely; pass color/size through tokens. Hand-written flex/grid class soup is the exception, not the hint — Flex/Grid cover it (see below). If you're writing more than a layout hint by hand, you're probably missing a primitive.

Sizing vs logical utilities (post-Tailwind-3)

Sizing is physical. Use w-* / h-* / min-w-* / max-h-* / size-* for width and height. The custom is-* / bs-* (inline-size / block-size) utilities were dropped — Tailwind core never shipped logical size utilities and keeps width/height physical, so is-full / bs-[20rem] are dead classes. Prefer w-full / h-[20rem].

Direction-sensitive spacing stays logical — these Tailwind ships and they flip correctly under RTL, so keep using them: ps-* / pe-* (padding), ms-* / me-* (margin), start-* / end-* (inset), border-s / border-e (border side), text-start / text-end (alignment). Do not rewrite these to physical (pl-, ml-, left-, text-left).

The tailwindcss-logical dialect is gone. Dropped in the Tailwind v4 migration (#10611), so every class it provided now compiles to nothing — silently. These are the ones that keep coming back, with what to write instead:

Dead classWrite
pis-* / pie-*ps-* / pe-*
pbs-* / pbe-*pt-* / pb-*
pli-* / plb-*px-* / py-*
mis-* / mie-*ms-* / me-*
mbs-* / mbe-*mt-* / mb-*
mli-* / mlb-*mx-* / my-*
is-* / bs-*w-* / h-*
min-is-* / min-bs-*min-w-* / min-h-*
max-is-* / max-bs-*max-w-* / max-h-*

This is the highest-frequency regression in this codebase, and the most expensive kind: nothing errors, nothing lints, the layout is merely wrong — and when the dead class was load-bearing (a min-bs-* floor reserving height, a min-is-0 letting a grid child shrink) the failure surfaces far from its cause. Grep your diff before committing:

git diff | grep -nE '\b(p|m)(is|ie|bs|be|li|lb)-|\b(min-|max-)?(is|bs)-'

Note the near-misses that ARE real: ps-*/pe-* and ms-*/me-* (Tailwind's own logical spacing) and inset-*/start-*/end-*. Only the -is-/-bs-/-li-/-lb- infixes above are dead.

Rule of thumb: width/height → physical; margin/padding/inset/border-side/text-align → logical.

Icons

Icons are Phosphor sprite references named ph--<icon>--<weight> (weights: regular, bold, fill, light, duotone, thin). Use the Icon primitive or any primitive that takes an icon prop:

import { Icon } from '@dxos/react-ui';
<Icon icon='ph--plus--regular' size={5} />;

size is a numeric Size (Tailwind scale), or inherit from the --dx-icon-size CSS var. See packages/ui/react-ui/src/components/Icon/Icon.tsx.

Nothing needs registering to use a new Phosphor icon — name it and it resolves. dx--* brand glyphs are regular-only. How resolution works (and why an icon might not appear) → packages/ui/react-ui/docs/icons.md.

Containers: Panel + ScrollArea

Panel.* (packages/ui/react-ui/src/components/Panel/Panel.tsx) is the container shell — a CSS grid with rows auto 1fr auto mapped to the toolbar / content / statusbar areas, so the content row absorbs the slack and the toolbar/statusbar hug their content. The canonical article shape:

<Panel.Root role={role}>
  <Panel.Toolbar>{/* Menu.Root toolbar — see below */}</Panel.Toolbar>
  <Panel.Content asChild>
    <ScrollArea.Root orientation='vertical'>
      <ScrollArea.Viewport>{/* List / Stack / Form, or ad-hoc content */}</ScrollArea.Viewport>
    </ScrollArea.Root>
  </Panel.Content>
</Panel.Root>

Parts: Panel.Root / Panel.Toolbar / Panel.Content / Panel.Statusbar. Add Panel.Statusbar (takes a size) only when the surface needs a persistent bottom status row — most articles don't.

role: Panel.Root defaults role to none. Only pass a role that the surface itself receives (the article/section/companion role threaded in via AppSurface.*Props) — don't invent ARIA roles to hang behaviour on.

asChild + composable. Every Panel.* part is slottable, so asChild makes the part become its single child instead of rendering its own <div>. Use asChild whenever the child is itself composable (e.g. ScrollArea.Root): one fewer DOM node, and the height chain passes straight through. ScrollArea provides the themed scrollbar and the height chain that lets content scroll — content that should scroll goes in ScrollArea.Viewport inside Panel.Content asChild.

Let components own their spacing. Form, List, and Stack each control their own padding and spacing — don't wrap them in a padded viewport or sprinkle p-*/space-* around them; that double-pads and fights their internal rhythm. Reserve hand-written layout hints (p-4 space-y-4, @container queries) on ScrollArea.Viewport for ad-hoc free-form content that has no owning component. Anything beyond a layout hint means you're probably missing a primitive — clear it with the user first.

Never introduce a wrapper <div> for styling. Wrappers break the height chain that ScrollArea relies on (a wrapper around an input once silently disabled scrolling). If a context-provider component (like Input.Root, which renders no DOM) has nowhere to hang a class, rely on parent-level layout rules rather than wrapping — and if there's genuinely no path without a wrapper, discuss it first.

See: plugin-chess/src/containers/ChessArticle/, plugin-sample/src/containers/.

Layout primitives: Flex, Grid, Column, Container

When you do need a box — inside ScrollArea.Viewport, between Panel parts, anywhere the shell doesn't already give you one — reach for these before writing <div className='flex …'>. All take asChild, so the layout can project onto a semantic element (<header>, <ul>) at no extra DOM node. Flex/Grid/Container live in packages/ui/react-ui/src/primitives/ (not components/); Column is in components/Column.

  • Flexcolumn, gap, align, justify, wrap, grow, center. grow is flex-1 overflow-hidden (the height-chain link); center centers on both axes.
  • Gridcols, rows, gap, align, center, grow, contents. Tracks take a count for equal columns (cols={3}) or a list for anything asymmetric (cols={['min-content', '1fr']}, cols={[2, 1]} for 2fr 1fr) — the list form replaces grid-cols-[min-content_1fr], which is the least readable class in the corpus. cols='subgrid' adopts the parent's tracks and spans them. overflow-hidden comes only with grow, so a grow={false} grid clips no more than the <div> it replaced.
  • Column — the gutter grid: three tracks (leading gutter / content / trailing gutter) sized by --gutter. This is what aligns icons, controls, and scrollbars to the same vertical rules across every surface, so use it instead of hand-padding a content column.
  • Container — a bare dx-expand box, for when the only job is to fill the parent. Add overflow-hidden yourself if a clip is also wanted; it is no longer implied.
<Flex column gap='sm'>…</Flex>
<Flex gap='sm' justify='end'>…</Flex>
<Flex center classNames='h-full text-subdued' role='status'>{t('empty.message')}</Flex>
<Flex asChild gap='sm'><header>…</header></Flex>

Column parts. Column.Root (gutter: sm|md|lg, subgrid, gap) defines the tracks and exposes --dx-col; Column.Center puts plain content in the centre track and is the default choice; Column.Row is a 3-track subgrid row for content flanked by gutter items; Column.Block is a gutter slot sized to --dx-rail-item (end for the trailing gutter) so a passive <Icon> and an interactive IconButton align to the pixel. For slotted children that can't take a part, the withColumn helpers apply placement: center(), placeContent(), propagate(). Reach for propagate() — not center() — when a descendant must address the gutters, e.g. a ScrollArea that should span full width and keep its scrollbar out in the gutter; Dialog.Body depends on exactly that, and center() there confines the body and pulls the scrollbar inboard. Nest with subgrid when a Column (or Card) sits inside another 3-track grid and must inherit its rules rather than invent new ones.

gap takes ramp steps, not Tailwind numbers. xs | sm | md | lg | xl | 2xl | form | form-section (primitives/layout.ts) — a gap-2 literal is precisely the drift the prop exists to prevent. Flex grows no padding or colour props on purpose (components own their spacing); everything else goes through classNames. There is no implicit align: row-centering is common, but defaulting it would silently restyle consumers relying on CSS stretch.

This is a live migration, so match it rather than adding to the backlog. packages/ui/react-ui/AUDIT.md is the wrapper-div census that produced Flex and drove the Grid extension: of 191 flex/grid wrappers in plugin containers, 145 are converted. A new hand-rolled flex or grid div is new debt in a count someone is actively driving down.

Lists, pickers, and stacks

Rule: never hand-roll a list. Any vertical collection of rows — even a read-only display list — is built from a @dxos/react-ui-list primitive, never a map() over <div>/<li>. A mapped stack of <div>s in a review is a defect; reach for the primitive below instead. @dxos/react-ui's core List/ListItem are deprecated — do not use them; Listbox is their successor.

Pick the collection primitive by decision order:

  1. Need a picker / combobox (choose from a set, typeahead)? Check for an existing one firstPicker / Combobox / Listbox in @dxos/react-ui-list, or a domain widget like SearchList (@dxos/react-ui-search). Reuse before building.
  2. A simple flat list (display rows, selectable rows, or rows with per-row controls)? Use Listbox from @dxos/react-ui-list: Listbox.Root (headless; omit value/onValueChange for a non-selectable role=list, pass them for single-select) → Listbox.Content (the <ul>) → Listbox.Item id=… classNames=… (a row; put arbitrary children — labels via Listbox.ItemLabel, buttons, a Select — inside). See plugin-space/src/components/ForeignKeys/ForeignKeys.tsx for the read-only idiom. For a reorderable / master-detail list use OrderedList; for hierarchy use Tree / Accordion.
  3. A reorderable / resizable / tiled collection of surfaces? Use the Stack from @dxos/react-ui-mosaic (MosaicStack / MosaicVirtualStack, with MosaicStackTileComponent tiles).

Do NOT use @dxos/react-ui-stack — it is deprecated. (Some plugins still import it; don't copy them.) The live Stack is the Mosaic one.

dx-current / dx-selected are automatic. Listbox and Stack drive current-item and selection state themselves (via react-tabster keyboard navigation) — you don't set those classes or wire focus by hand. Like Form, both own their own padding and spacing, so drop them straight into a ScrollArea.Viewport without a padded wrapper.

Toolbar / menu wiring

Container toolbars are always built from menu actions, never bare Toolbar.IconButton chains. Compose actions with MenuBuilder inside an Atom, thread them through useMenuActions, and render with Menu.Root — passing attendableId so attention-driven contributions (graph actions, plugin extensions, keyboard shortcuts) target the right surface. Skipping this breaks plugin composition.

const actionsAtom = useMemo(
  () =>
    Atom.make((): ActionGraphProps =>
      MenuBuilder.make()
        .action(
          'add',
          { label: ['add.label', { ns: meta.id }], icon: 'ph--plus--regular', disposition: 'toolbar' },
          handleAdd,
        )
        .build(),
    ),
  [handleAdd],
);
const menuActions = useMenuActions(actionsAtom);

return (
  <Panel.Toolbar>
    <Menu.Root {...menuActions} attendableId={attendableId}>
      <Menu.Toolbar />
    </Menu.Root>
  </Panel.Toolbar>
);

Encode dynamic state (disabled, busy/idle icon swap) inside the action properties and list that state in the useMemo deps — the atom rebuilds and the toolbar updates. Always thread attendableId from AppSurface.ObjectArticleProps; don't underscore it as unused.

See: plugin-sample/src/containers/SampleArticle.tsx.

Reactivity

State lives in one of three stores — React state (ephemeral, local), atoms (shared/derived), ECHO objects (persistent, collaborative) — and reading an ECHO object during render does not subscribe: subscribe where you read (useObject / useQuery), as narrowly as you read, and write through the live object, never the snapshot. The house rules and anti-pattern catalog (bare reads, .target in render, list-level ref resolution, hook pileups, effect-syncing between stores) live in the reactivity skill — load it for any component that holds or reads state.

State management

Two app-level homes for atom state — don't conflate them (full detail: packages/ui/react-ui-attention/AUDIT.md):

  • Settings — a user preference, set infrequently, applies globally, shown in the Settings UI. Built with createKvsStore (one schema-validated blob per plugin, keyed by meta.profile.key); read/write via useAtomCapabilityState(XCapabilities.Settings). Idiom org.dxos.effect.kvsStore.
  • ViewState — the current, sticky UI state that survives navigation (selection, scroll, split, view mode). Per-context: keyed by (aspect, contextId). Declare once with define({ key, backend, schema, defaultValue }); the backend sets durability — 'local' persists across reloads (best-effort; degrades to memory when storage is blocked), 'memory' is session-only. Read/write via useViewState / useViewStateActions (React), or Capability.get(AttentionCapabilities.ViewState) (operations / graph-builders). Idiom org.dxos.react-ui-attention.viewState.

The tell: configure-once-and-forget → Settings; tracks-what-you're-currently-doing → ViewState. Keep at most one Settings object and one ViewState object per aspect per plugin — widen an existing schema, don't add a parallel store.

Passing state into low-level components (which must not resolve capabilities): prefer a writable atom over a value + onChange pair — simpler, and it needs no provider ancestor, so the component stays generic. Caveat: a ViewState local atom does not self-persist on a direct set — persistence lives in manager.set. To hand ViewState down as one atom (e.g. combined with a settings field), use a writable derived atom whose write calls manager.set (see MessageArticle's optionsAtom).

Consider factoring each state concern into a small file-local hook (e.g., useMessageExpansion, useThreadViewActions) so the container body reads as a sequence of named concerns instead of an inline wall.

Forms

Never hand-roll native <input> / <textarea> / <select> in a plugin — they don't inherit the theme (a bare textarea is a white box in dark mode) and bypass validation. Edit objects with the schema-driven Form from @dxos/react-ui-form, which renders themed inputs from the Effect Schema (strings, numbers, booleans, enums via Schema.Literal/Format, nested Schema.Struct, Schema.Array, Schema.Record).

Form is composed — Form.Root renders nothing on its own. The fields come from Form.FieldSet (or Form.Layout), nested inside the standard Radix wrapper pair: Form.Viewport (outer) → Form.Content (inner), which own scroll and padding (so, like List/Stack, don't pad them yourself):

<Form.Root schema={Type.getSchema(Foo)} values={obj} autoSave onSave={handleSave}>
  <Form.Viewport>
    <Form.Content>
      <Form.Section label='…' description='…' /> {/* optional grouping */}
      <Form.FieldSet /> {/* fields, generated from the schema */}
      <Form.Actions /> {/* Save/Cancel — omit when autoSave */}
    </Form.Content>
  </Form.Viewport>
</Form.Root>
  • Form.FieldSet is driven entirely by the schema and its annotations — fields, order, labels, visibility. Hide a field with FormInputAnnotation.set(false); there's no manual field markup.
  • Form.Layout template={…} is the alternative to FieldSet: a custom layout DSL for arranging fields (grouping, columns, ordering) when the default schema order isn't enough.

Save model — the form never mutates values; the parent applies the change. Pick a mode:

  • autoSave + onSave — on blur, if valid and changed, calls onSave(values, { changed, isValid }). This is the usual ECHO-object pattern: onSave writes back via Obj.update. No Form.Actions needed.
  • onSave without autoSaveonSave fires only on explicit submit (Form.Actions / Form.Submit, gated by canSave). Use when you want a deliberate Save/Cancel.
  • onValuesChanged — controlled: fires on every change with merged values + meta; the parent holds the state. Pair with values.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
520
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
composer-ui
Source
github.com/dxos/dxos