| name | composer-ui |
| description | Use when building or styling plugin UI with Composer's design system — the `@dxos/react-ui*` packages. Covers theme tokens, primitives (Panel/Card/List/Input/Button/Icon), the standard container layout (Panel + ScrollArea), lists/pickers/stacks, schema-driven forms, toolbar/menu wiring, reactivity (useObject), attention/density, translations, and storybook setup. The UI adjunct to the composer-plugins skill; consult it whenever you write a container/component, reach for a Tailwind color class, build a toolbar, render a form or list, or add a story. |
Composer UI
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:
| Package | Provides |
|---|
@dxos/react-ui | Core primitives: Panel, Card, List, Input, Button, IconButton, Icon, ScrollArea, Toolbar, Dialog, Popover, Tooltip, Select, Tag, Avatar, Separator, plus useTranslation, useThemeContext, DensityProvider. |
@dxos/react-ui-theme | The theme (tx resolver, tokens, Tailwind preset). You rarely import from it directly — tokens are plain Tailwind classes. |
@dxos/react-ui-form | Form.* — schema-driven forms (the way to edit ECHO objects). |
@dxos/react-ui-menu | Menu.*, MenuBuilder, useMenuActions — toolbars and command menus. |
@dxos/react-ui-attention | Attention system: AttentionGlyph, useAttention, attendable wiring. |
@dxos/react-ui-list | Navigable lists with dx-current/dx-selected item states. |
@dxos/react-ui-mosaic / -board | Layout 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 / -markdown | Text/markdown editing. |
@dxos/react-ui-table / -data | Data 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 @dxos/ui-theme/src/css/theme/semantic.css
and the per-component files under @dxos/ui-theme/src/css/components/.
The rule: every --color-<name> custom property yields the utilities bg-<name>, text-<name>,
border-<name>. So --color-modal-surface → bg-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, flex, @container queries) freely; pass color/size
through tokens. 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).
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 @dxos/react-ui/src/components/Icon/Icon.tsx.
Containers: Panel + ScrollArea
Panel.* (@dxos/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/.
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:
- Need a picker / combobox (choose from a set, typeahead)? Check for an existing one first —
Picker / Combobox / Listbox in @dxos/react-ui-list,
or a domain widget like SearchList (@dxos/react-ui-search). Reuse before building.
- 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.
- 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
When an ECHO object is passed into a component as a prop and the component must re-render on changes to it,
wrap it with useObject and read from the returned snapshot. A surface receiving an ECHO subject (e.g.
via AppSurface.ObjectArticleProps<T>) MUST do this — without it, mutations to nested arrays/structs (e.g.
Obj.update(obj, (m) => (m.images = [...]))) don't trigger a re-render until you navigate away and back:
the prop reference stays stable, and the subscription lives inside useObject.
const [gallery] = useObject(subject);
const handleDelete = (index: number) =>
Obj.update(subject, (obj) => {
const mutable = obj as Obj.Mutable<Gallery.Gallery>;
mutable.images = (mutable.images ?? []).filter((_, idx) => idx !== index);
});
The snapshot type is narrow — cast as needed (obj as Obj.Mutable<T> inside Obj.update, or as T to
read fields not surfaced on Snapshot<T>). For collections of objects use the reactive useQuery
rather than holding a plain array. (Pure presentational components that just receive scalar props don't
need any of this — keep useObject at the container boundary where the ECHO object enters.)
State management
Two state stores — don't conflate them (full detail:
@dxos/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 autoSave — onSave 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.
values is the controlled current value; defaultValues seeds an uncontrolled form that keeps its own
internal state. To edit an opaque document (e.g. a stored JSON Schema), model it with typed sub-schemas
and render those rather than dropping to a <textarea>. For a one-off input not backed by a schema
object, use Input.Root + Input.TextInput.
Custom field renderers. When a field needs an editor the schema can't express, supply a
FormFieldComponent (an FC<FormFieldComponentProps> — label, readonly, and value/onChange wiring) —
never a native element. Choose how to register it by when you know which fields need it:
fieldMap: Record<jsonPath, FormFieldComponent> — static, when you know the property paths ahead of
time. Override the renderer for specific named fields.
fieldProvider: (props) => FormFieldComponent | undefined — dynamic, when you must decide at runtime
(e.g. by type or annotation rather than exact path). Preferred for plugin-specific input surfaces.
See: @dxos/react-ui-form/src/components/Form/Form.stories.tsx
(a dedicated canonical custom-field example is planned — tracked separately).
Cards: 3-slot subgrid
Card.Header and Card.Row are 3-slot subgrids (grid-cols-subgrid: leading icon · 1fr content ·
trailing action), placed by child order. A lone <Card.Title> lands in the narrow leading slot and
gets clamped (a title renders as "20…"). Put real content in the centre slot — bracket it with icon slots
or wrap it in one element occupying slot 2:
<Card.Header>
<Card.IconBlock /> {}
<div className='flex flex-col gap-0.5 min-w-0'>
{' '}
{}
<Card.Title classNames='line-clamp-2'>{title}</Card.Title>
{price && <span className='text-sm text-description'>{price}</span>}
</div>
<Card.IconBlock /> {}
</Card.Header>
Content parts must live inside a subgrid part, never directly under Card.Root. Card.Title,
Card.Text, and Card.Block carry no column placement of their own — the center-track placement comes
from Card.Header / Card.Row / Card.Section. A Card.Text (or Card.Title) dropped straight into
Card.Root — or into Card.Body, which is display:contents and so doesn't place either — CSS-grid
auto-places into a gutter track and renders squeezed/clamped. Wrap free body text in a Card.Row:
<Card.Root>
<Card.Header>
<Card.Title>…</Card.Title>
</Card.Header>
<Card.Row>
<Card.Text variant='description'>…</Card.Text> {/* NOT a direct Card.Root/Card.Body child */}
</Card.Row>
</Card.Root>
A card used as the child of Focus.Item asChild (or any Radix Slot/asChild) must be composable — a
single element that forwards ref and spreads injected props, or the Slot's ref/handlers silently drop
and current/keyboard/click wiring never attaches. Make presentational cards forwardRef and spread:
export const FooCard = forwardRef<HTMLDivElement, FooCardProps>(({ subject, current, classNames, ...props }, ref) => (
<Card.Root ref={ref} classNames={['dx-hover', current && 'dx-current', classNames]} {...props}>
…
</Card.Root>
));
For authoring brand-new composite primitives (Radix-style Foo.Root/Foo.Content with slottable() /
composableProps), see [[composite-components]].
Attention & density
- Attention (
@dxos/react-ui-attention): surfaces carry an attendableId; focusing one registers it
so contributions target it and AttentionGlyph reflects focus. As a plugin author you mostly just
thread attendableId into Menu.Root (above) and let the shell handle the rest.
- Density (
DensityProvider / useDensity, values xs|sm|md|lg): scales spacing and hit-areas.
Primitives read it automatically; wrap a region in <DensityProvider density='…'> only to override.
Translations (i18n)
Use useTranslation(meta.id) for plugin-scoped strings and reference labels as ['key', { ns: meta.id }]
(as in the menu action above). Resources are keyed by language → namespace → key, declared in the plugin's
translations.ts and contributed via addTranslationsModule. See [[composer-plugins]] for registration.
Storybook
Every major component and container gets a .stories.tsx beside it — the user reviews agent-built UI
primarily through storybook, so a missing story means the component effectively doesn't exist for review.
Start with a very basic story for each (mount it with realistic props); add variants later. Mount it
with these decorators (import from @dxos/react-ui/testing):
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { withLayout, withTheme } from '@dxos/react-ui/testing';
import { translations } from '#translations';
const meta = {
title: 'plugins/plugin-foo/FooView',
component: FooView,
decorators: [withTheme(), withLayout({ layout: 'centered' })],
parameters: { translations },
} satisfies Meta<typeof FooView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {},
};
Two things break silently if omitted:
withTheme() must be CALLED with parens — it's a factory returning the decorator. Passing
withTheme (no parens) passes the factory itself and theme/types break.
parameters: { translations } (from #translations) is required for useTranslation labels to
resolve. Without it, triggers show the raw key (foo.add.label) instead of the translated text — which
looks like a bug in a screenshot but isn't.
For a container with complex data behavior (loading, mutation, multi-step interaction), add a Storybook
play function that drives and asserts the interaction — a basic static story isn't enough to catch
regressions in behaviour. Keep the basic story too; play is the second step, not a replacement.
Capability hooks (useCapability, useAppGraph, useOperationInvoker) throw in storybook (no
PluginManager). Keep those in containers/ and take resolved values as props in components/ so the
component is storybook-mountable. See [[composer-plugins]] ("Container vs Component").
See: plugin-sample/src/containers/SampleArticle.stories.tsx,
plugin-chess/src/components/Chessboard/Chessboard.stories.tsx.
Verifying a story in a worktree
preview_start serves storybook from the main repo, so it won't include stories that exist only in a
worktree. To verify worktree UI, run storybook from the worktree on a free port and drive it with
Playwright:
moon run <plugin>:storybook
Find story ids via curl -s localhost:9014/index.json, navigate to
http://localhost:9014/iframe.html?id=<story-id>&viewMode=story. Screenshots go to temp/ (gitignored),
never the repo root. If a story renders empty with "Invalid hook call" / "Cannot read … 'useEffect'" /
504 "Outdated Optimize Dep", that's Vite dep-optimizer churn (dual React), not your code — kill storybook,
rm -rf node_modules/.cache/storybook, restart. Clean up the port and cache when done.
Checklist
- Layout from
Panel.* + ScrollArea.*; no wrapper <div>s for styling; asChild when the child is composable.
- Let
Form/List/Stack own their padding/spacing — don't double-pad them.
- Collections: never hand-roll a list of mapped
<div>s — existing picker/combobox → react-ui-list (Listbox for flat lists; OrderedList/Tree/Accordion otherwise) → Mosaic Stack. @dxos/react-ui List/ListItem and @dxos/react-ui-stack are deprecated.
- Colors from verified tokens (grep
semantic.css / copy a component); no invented tokens, no className.
- Toolbars via
MenuBuilder + useMenuActions + Menu.Root with attendableId.
- Object editing via composed
Form (Viewport/Content/FieldSet) + schema; no native inputs; form never mutates values.
- ECHO object passed into a component → wrap with
useObject at the container boundary.
- Icons as
ph--<icon>--<weight>.
- Every major component/container has a basic
.stories.tsx with withTheme() (parens) + parameters: { translations }; add a play function for complex data behaviour.
- Authoring a new
Foo.Root/Foo.Content primitive → [[composite-components]]; plugin wiring/surfaces → [[composer-plugins]].