Use this skill when building or revising packages/ui primitive APIs. The public
@remix-run/ui/* entries are sourced from packages/ui/src/* and are not
available through the remix package.
Source Layout
Each public primitive has a top-level packages/ui/src/<name>.ts or
packages/ui/src/<name>.tsx entry.
Common files:
<name>.tsor<name>.tsx: the public primitive entry and implementation.<name>/README.md: usage docs for the primitive.<name>.test.tsor<name>.test.tsx: primitive tests.demos/<name>.demo.tsx: demo cases for the shared UI demo app.lib/*: shared implementation-only behavior used by multiple primitives.
Current examples:
- low-level behavior modules:
anchor,popover,listbox - composed behavior modules:
combobox,menu,select - self-contained controls:
accordion,tabs,toggle
When adding or moving public entries, update both exports and
publishConfig.exports in packages/ui/package.json. Public entries use the
flat ./<name> path.
Layering
Choose the smallest layer that fits the job.
- A headless primitive owns behavior, ARIA, registration, keyboard handling, refs, and public events. It exports named providers and mixins.
- A shared primitive utility belongs in
src/libonly when multiple primitives already need it.
Good composition flows downward:
selectcomposespopoverandlistbox.comboboxcomposespopoverandlistbox, then owns input text, filtering, and popup timing.menucomposespopover, outside interactions, typeahead, and hover aim.accordion,tabs, andtogglekeep their own primitive contexts.
Public API Shape
Primitive modules export named bindings, and callers namespace them at import time:
import * as select from '@remix-run/ui/select'
function StatusSelect() {
return () => (
<select.Context defaultLabel="Status">
<button type="button" mix={select.trigger()} />
<div mix={select.popover()}>
<div mix={select.list()}>
<div mix={select.option({ label: 'Open', value: 'open' })}>Open</div>
</div>
</div>
</select.Context>
)
}
Use Context, ItemContext, or GroupContext for providers, and short role
names for mixins such as trigger, popover, list, option, hiddenInput,
root, item, content, panel, parent, or control.
Do not add a public namespace object just to group exports. The established
pattern is named exports plus import * as name.
README examples should import primitives from @remix-run/ui/... and component
APIs from remix/component. Source files in this package import other primitive
APIs from @remix-run/ui/... and component APIs from @remix-run/component.
Component Runtime
Primitive components use the current Remix component two-phase shape:
export function Component(handle: Handle<ComponentProps>): () => RemixNode {
let hasInitialized = false
let value: string | null = null
return () => {
if (!hasInitialized) {
value = handle.props.defaultValue ?? null
hasInitialized = true
}
return handle.props.children
}
}
Rules:
- Read props from the stable
handle.propsobject in setup and render code. - Keep component lifetime state in setup scope and schedule renders with
handle.update(). - Initialize uncontrolled state lazily from
handle.propsduring the first render when defaults depend on current props or registered children. - Await
handle.update()before DOM work that depends on the next rendered tree; check the returned signal before continuing async flows. - Use
handle.queueTask()for post-render ref callbacks, registration checks, popup show/hide work, CSS-transition follow-up, and public ref delivery.
Context Providers
Providers scope one behavior instance. Prefer plain context objects with getters and methods over controller classes.
Use context for:
- current prop-backed state through getters
- registered descendants, root nodes, trigger nodes, surfaces, and lists
- methods such as
open(),close(),navigate(),select(),highlight(),toggleItem(), orsetInputText() - stable public refs backed by getters and methods
Provider patterns:
- Call
handle.context.set(...)once in setup scope when the object can stay stable. - Reset render-scoped registries in the render function when children re-register each render.
- Use a "next registry" plus a queued comparison when child registration changes should trigger a follow-up render.
- Use nested item providers when one item needs per-item context, like
AccordionItemProvider. - Return
handle.props.childrenunless the provider must compose lower-level providers, likeSelectProviderreturninglistbox.Context.
Do not add event emitters to context just to wake descendants. Normal component updates, getters, and context methods are the default coordination layer.
Mixins
Mixins adapt one host element to one behavior role.
Use createMixin with explicit host, argument, and prop types:
const triggerMixin = createMixin<HTMLButtonElement, [], ElementProps>((handle) => {
let context = handle.context.get(SelectProvider)
return (props) => [
attrs({
'aria-haspopup': 'listbox',
'aria-expanded': context.isExpanded ? 'true' : 'false',
disabled: context.disabled ? true : props.disabled,
}),
ref((node: HTMLButtonElement, signal) => {
context.registerTrigger(node)
signal.addEventListener('abort', () => {
context.unregisterTrigger(node)
})
}),
on('click', () => {
context.open()
}),
]
})
Mixin rules:
- Own one role and one host element.
- Derive ARIA,
data-*,hidden,disabled,tabIndex, and ids from context getters. - Normalize DOM input locally, then call context methods.
- Keep role-specific keyboard parsing in the role mixin.
- Register host nodes with
ref(...),handle.queueTask(...), or an insert listener, and clean up registrations with the abort signal when needed. - For mixins that support both native inputs and custom elements, use
hostType,createElement(...), orrenderMixinElement(...)to rewrite props safely.
If a mixin accepts optional options and also receives host props, follow the
existing options = {}, props = options as ElementProps pattern when needed to
distinguish authored options from host props.
DOM And Async Work
Keep imperative DOM work near the owner of the relevant ref.
popover.surface()ownsshowPopover(),hidePopover(), outside click, focus restoration, anchoring, and scroll locking.select.popover()andcombobox.popover()own popup min-width syncing because they know their trigger/input refs.listboxowns option scrolling and selection flash.menuowns branch closing, focus transfer, hover aim, and close animation sequencing.
When waiting for transitions or timers, use existing utilities such as
waitForCssTransition(...), wait(...), and flashAttribute(...), then check
the component signal before dispatching events or updating more state.
Public Events
Use bubbling DOM events for public primitive contracts, not for internal coordination.
Established public event pattern:
- define a
const EVENT_NAME = 'rmx:<primitive>-<action>' as const - declare the event on
HTMLElementEventMap - export an event class with readonly payload fields
- dispatch from the meaningful host/root node
- export an
on<Primitive><Action>(handler)mixin that wrapson(...) - also support callback props such as
onValueChangewhen the primitive is stateful
Examples include SelectChangeEvent, ComboboxChangeEvent, MenuSelectEvent,
AccordionChangeEvent, TabsChangeEvent, and ToggleChangeEvent.
Styling
Primitives may use css(...) only for structural styles required for correct
positioning, visibility, scrolling, or interaction. Typography, colours,
spacing, borders, shadows, radii, decorative icons, and themes belong to the
consumer.
Testing And Docs
For behavior changes, test the primitive directly.
Add or update:
<name>.test.tsxfor provider, mixin, keyboard, focus, registration, and event behavior- browser tests only when DOM behavior needs a real browser
- demos under
packages/ui/src/demos <name>/README.mdexamples that import from@remix-run/ui/...- package change files when published behavior changes
Checklist
Before finishing a packages/ui primitive change, verify:
- the public entry is
packages/ui/src/<name>.tsorpackages/ui/src/<name>.tsxand exports matchpackages/ui/package.json - implementation-only shared helpers live under
packages/ui/src/lib - primitives use named exports and are consumed with namespace imports
- context is a plain getter/method object, not a controller or emitter layer
- host refs are registered and unregistered with the owning mixin/component
- controlled and uncontrolled state paths are both covered when the existing contract supports them
- public events use the established event class plus
on...mixin pattern - structural styles are limited to what behavior requires
- docs, demos, tests, and change files match the public surface touched
Anti-Patterns
Avoid:
- adding public APIs under
src/lib - exporting a namespace object when named exports already fit
- re-implementing
popover,listbox, typeahead, outside-click, or keyboard helpers inside a higher-level primitive - duplicating selected, active, open, or input state across layers without a clear owner
- using DOM events or context emitters for internal re-render signaling
- placing one-off helpers in
src/libbefore a second primitive needs them