Author UI Components
Use this skill when building or revising packages/ui component APIs. In current
Remix UI, the public remix/ui/* and @remix-run/ui/*
entries are sourced from packages/ui/src/*.
Source Layout
Component modules live in packages/ui/src/<name>/.
Common files:
index.ts or index.tsx: the primary public component entry.
primitives.ts or primitives.tsx: lower-level headless behavior when the
component also has styled wrappers.
README.md: usage docs for both styled components and primitives.
*.demo.tsx: demo cases for the UI demo app.
index.test.tsx and primitives.test.tsx: wrapper and primitive tests.
shared/*: component-only visual tokens, icons, and reusable style mixins.
Current examples:
- style-only helpers:
button, input
- low-level behavior modules:
anchor, popover, listbox
- primitives plus styled wrappers:
accordion, checkbox, combobox, menu,
select
- rendered styled components without a primitive layer:
breadcrumbs
When adding or moving public entries, update both exports and
publishConfig.exports in packages/ui/package.json. Prefer the
./<name> path for component docs and styled wrappers, with lower-level
behavior under ./<name>/primitives when a primitive layer exists.
Layering
Choose the smallest layer that fits the job.
- A style helper returns
css(...) descriptors and optionally a tiny default-attr
mixin, like button() or input.root().
- A headless primitive owns behavior, ARIA, registration, keyboard handling,
refs, and public events. It exports named providers and mixins.
- A styled wrapper composes primitives, shared styles, icons, and authored
structure. It should not duplicate primitive behavior.
- A shared component utility belongs in
src/shared only when multiple
components already need it.
Good composition flows downward:
select composes popover and listbox.
combobox composes popover and listbox, then owns input text, filtering,
and popup timing.
menu composes popover, outside interactions, typeahead, and hover aim.
accordion and checkbox keep their own primitive contexts and use styled
wrappers for the ergonomic API.
Public API Shape
Primitive modules export named bindings, and callers namespace them at import
time:
import * as select from '@remix-run/ui/select/primitives'
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.
Styled wrapper modules export named components and reusable style constants:
export const triggerStyle = selectTriggerCss
export function Select(handle: Handle<SelectProps>): () => RemixNode {
return () => {
let { children, defaultLabel, defaultValue, disabled, name, mix, ...buttonProps } = handle.props
return (
<select.Context defaultLabel={defaultLabel} defaultValue={defaultValue} disabled={disabled}>
<button {...buttonProps} type="button" mix={[triggerStyle, select.trigger(), mix]}>
...
</button>
{name && <input mix={select.hiddenInput()} />}
</select.Context>
)
}
}
README examples should import from remix/ui/.... Source files in this
package usually import public UI APIs from @remix-run/ui and component APIs
from @remix-run/ui/....
Component Runtime
Components use the current Remix UI 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.props object in setup and render code.
- Keep component lifetime state in setup scope and schedule renders with
handle.update().
- Initialize uncontrolled state lazily from
handle.props during 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(), or setInputText()
- 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, like checkbox groups.
- Use nested item providers when one item needs per-item context, like
AccordionItemProvider.
- Return
handle.props.children unless the provider must compose lower-level
providers, like SelectProvider returning listbox.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(...), or renderMixinElement(...) to rewrite props safely.
- Put user
mix last in styled wrapper arrays so callers can extend the
composed component.
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() owns showPopover(), hidePopover(), outside click,
focus restoration, anchoring, and scroll locking.
select.popover() and combobox.popover() own popup min-width syncing because
they know their trigger/input refs.
listbox owns option scrolling and selection flash.
menu owns branch closing, focus transfer, hover aim, and close animation
sequencing.
- Checkbox primitives own native input synchronization, including
indeterminate.
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 component contracts, not for internal
coordination.
Established public event pattern:
- define a
const EVENT_NAME = 'rmx:<component>-<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<Component><Action>(handler) mixin that wraps on(...)
- also support callback props such as
onValueChange when the component is
stateful
Examples include SelectChangeEvent, ComboboxChangeEvent, MenuSelectEvent,
AccordionChangeEvent, CheckboxChangeEvent, and CheckboxGroupChangeEvent.
Styling
Use css(...) descriptors and shared component tokens.
- Prefer
componentStyleValues for cross-component spacing, radii, surfaces,
text colors, focus rings, and control heights.
- Reuse
shared/listbox-popover-styles.ts for listbox-like popover surfaces,
lists, options, labels, and indicators.
- Export style constants such as
triggerStyle, listStyle, itemStyle, or
popoverStyle when custom composition is documented.
- Keep shared CSS helpers in
src/shared only when multiple
components consume them.
- For simple style helpers, return readonly arrays of CSS and behavior mixins
with precise public option types.
Testing And Docs
For behavior changes, test the primitive layer directly. For styled wrappers,
test that the wrapper composes the expected primitive roles, attributes, hidden
inputs, and public events.
Add or update:
primitives.test.tsx for provider, mixin, keyboard, focus, registration, and
event behavior
index.test.tsx for styled wrapper markup and composition
- browser tests only when DOM behavior needs a real browser
- demos for visible components; for new built-in component modules, add the
module name to
componentDemoModules in
packages/ui/demo/app/demo-runner/view.tsx so src/<name>/*.demo.tsx files
appear under the "Built-in components" heading instead of "General demos"
README.md examples that import from remix/ui/...
- package change files when published behavior changes
Checklist
Before finishing a packages/ui component change, verify:
- source lives under
packages/ui/src/<name> and exports match
packages/ui/package.json
- the API layer is correct: style helper, primitive module, styled wrapper, or
shared utility
- primitives use named exports and are consumed with namespace imports
- styled wrappers compose primitives instead of copying their behavior
- 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
- public events use the established event class plus
on... mixin pattern
- built-in component demos are listed in the demo app's
componentDemoModules allowlist
- docs, demos, tests, and change files match the public surface touched
Anti-Patterns
Avoid:
- adding new modules under
src/lib for component public APIs
- exporting a namespace object when named exports already fit
- re-implementing
popover, listbox, typeahead, outside-click, or keyboard
helpers inside a higher-level control
- duplicating selected, active, open, or input state across layers without a clear
owner
- using DOM events or context emitters for internal re-render signaling
- wrapping every primitive in styled markup when the lower-level primitive is the
actual product
- placing one-off visual constants in
shared before a second component needs
them
1---2name: author-ui-components3description: Build idiomatic `packages/ui` components for Remix. Use when authoring or revising first-party UI style mixins, headless primitives, styled component wrappers, or shared component utilities under `packages/ui/src`.4---56# Author UI Components78Use this skill when building or revising `packages/ui` component APIs. In current9Remix UI, the public `remix/ui/*` and `@remix-run/ui/*`10entries are sourced from `packages/ui/src/*`.1112## Source Layout1314Component modules live in `packages/ui/src/<name>/`.1516Common files:1718- `index.ts` or `index.tsx`: the primary public component entry.19- `primitives.ts` or `primitives.tsx`: lower-level headless behavior when the20 component also has styled wrappers.21- `README.md`: usage docs for both styled components and primitives.22- `*.demo.tsx`: demo cases for the UI demo app.23- `index.test.tsx` and `primitives.test.tsx`: wrapper and primitive tests.24- `shared/*`: component-only visual tokens, icons, and reusable style mixins.2526Current examples:2728- style-only helpers: `button`, `input`29- low-level behavior modules: `anchor`, `popover`, `listbox`30- primitives plus styled wrappers: `accordion`, `checkbox`, `combobox`, `menu`,31 `select`32- rendered styled components without a primitive layer: `breadcrumbs`3334When adding or moving public entries, update both `exports` and35`publishConfig.exports` in `packages/ui/package.json`. Prefer the36`./<name>` path for component docs and styled wrappers, with lower-level37behavior under `./<name>/primitives` when a primitive layer exists.3839## Layering4041Choose the smallest layer that fits the job.4243- A style helper returns `css(...)` descriptors and optionally a tiny default-attr44 mixin, like `button()` or `input.root()`.45- A headless primitive owns behavior, ARIA, registration, keyboard handling,46 refs, and public events. It exports named providers and mixins.47- A styled wrapper composes primitives, shared styles, icons, and authored48 structure. It should not duplicate primitive behavior.49- A shared component utility belongs in `src/shared` only when multiple50 components already need it.5152Good composition flows downward:5354- `select` composes `popover` and `listbox`.55- `combobox` composes `popover` and `listbox`, then owns input text, filtering,56 and popup timing.57- `menu` composes `popover`, outside interactions, typeahead, and hover aim.58- `accordion` and `checkbox` keep their own primitive contexts and use styled59 wrappers for the ergonomic API.6061## Public API Shape6263Primitive modules export named bindings, and callers namespace them at import64time:6566```tsx67import * as select from '@remix-run/ui/select/primitives'6869function StatusSelect() {70 return () => (71 <select.Context defaultLabel="Status">72 <button type="button" mix={select.trigger()} />73 <div mix={select.popover()}>74 <div mix={select.list()}>75 <div mix={select.option({ label: 'Open', value: 'open' })}>Open</div>76 </div>77 </div>78 </select.Context>79 )80}81```8283Use `Context`, `ItemContext`, or `GroupContext` for providers, and short role84names for mixins such as `trigger`, `popover`, `list`, `option`, `hiddenInput`,85`root`, `item`, `content`, `panel`, `parent`, or `control`.8687Do not add a public namespace object just to group exports. The established88pattern is named exports plus `import * as name`.8990Styled wrapper modules export named components and reusable style constants:9192```tsx93export const triggerStyle = selectTriggerCss9495export function Select(handle: Handle<SelectProps>): () => RemixNode {96 return () => {97 let { children, defaultLabel, defaultValue, disabled, name, mix, ...buttonProps } = handle.props9899 return (100 <select.Context defaultLabel={defaultLabel} defaultValue={defaultValue} disabled={disabled}>101 <button {...buttonProps} type="button" mix={[triggerStyle, select.trigger(), mix]}>102 ...103 </button>104 {name && <input mix={select.hiddenInput()} />}105 </select.Context>106 )107 }108}109```110111README examples should import from `remix/ui/...`. Source files in this112package usually import public UI APIs from `@remix-run/ui` and component APIs113from `@remix-run/ui/...`.114115## Component Runtime116117Components use the current Remix UI two-phase shape:118119```tsx120export function Component(handle: Handle<ComponentProps>): () => RemixNode {121 let hasInitialized = false122 let value: string | null = null123124 return () => {125 if (!hasInitialized) {126 value = handle.props.defaultValue ?? null127 hasInitialized = true128 }129130 return handle.props.children131 }132}133```134135Rules:136137- Read props from the stable `handle.props` object in setup and render code.138- Keep component lifetime state in setup scope and schedule renders with139 `handle.update()`.140- Initialize uncontrolled state lazily from `handle.props` during the first141 render when defaults depend on current props or registered children.142- Await `handle.update()` before DOM work that depends on the next rendered tree;143 check the returned signal before continuing async flows.144- Use `handle.queueTask()` for post-render ref callbacks, registration checks,145 popup show/hide work, CSS-transition follow-up, and public ref delivery.146147## Context Providers148149Providers scope one behavior instance. Prefer plain context objects with getters150and methods over controller classes.151152Use context for:153154- current prop-backed state through getters155- registered descendants, root nodes, trigger nodes, surfaces, and lists156- methods such as `open()`, `close()`, `navigate()`, `select()`,157 `highlight()`, `toggleItem()`, or `setInputText()`158- stable public refs backed by getters and methods159160Provider patterns:161162- Call `handle.context.set(...)` once in setup scope when the object can stay163 stable.164- Reset render-scoped registries in the render function when children165 re-register each render.166- Use a "next registry" plus a queued comparison when child registration changes167 should trigger a follow-up render, like checkbox groups.168- Use nested item providers when one item needs per-item context, like169 `AccordionItemProvider`.170- Return `handle.props.children` unless the provider must compose lower-level171 providers, like `SelectProvider` returning `listbox.Context`.172173Do not add event emitters to context just to wake descendants. Normal component174updates, getters, and context methods are the default coordination layer.175176## Mixins177178Mixins adapt one host element to one behavior role.179180Use `createMixin` with explicit host, argument, and prop types:181182```tsx183const triggerMixin = createMixin<HTMLButtonElement, [], ElementProps>((handle) => {184 let context = handle.context.get(SelectProvider)185186 return (props) => [187 attrs({188 'aria-haspopup': 'listbox',189 'aria-expanded': context.isExpanded ? 'true' : 'false',190 disabled: context.disabled ? true : props.disabled,191 }),192 ref((node: HTMLButtonElement, signal) => {193 context.registerTrigger(node)194 signal.addEventListener('abort', () => {195 context.unregisterTrigger(node)196 })197 }),198 on('click', () => {199 context.open()200 }),201 ]202})203```204205Mixin rules:206207- Own one role and one host element.208- Derive ARIA, `data-*`, `hidden`, `disabled`, `tabIndex`, and ids from context209 getters.210- Normalize DOM input locally, then call context methods.211- Keep role-specific keyboard parsing in the role mixin.212- Register host nodes with `ref(...)`, `handle.queueTask(...)`, or an insert213 listener, and clean up registrations with the abort signal when needed.214- For mixins that support both native inputs and custom elements, use `hostType`,215 `createElement(...)`, or `renderMixinElement(...)` to rewrite props safely.216- Put user `mix` last in styled wrapper arrays so callers can extend the217 composed component.218219If a mixin accepts optional options and also receives host props, follow the220existing `options = {}, props = options as ElementProps` pattern when needed to221distinguish authored options from host props.222223## DOM And Async Work224225Keep imperative DOM work near the owner of the relevant ref.226227- `popover.surface()` owns `showPopover()`, `hidePopover()`, outside click,228 focus restoration, anchoring, and scroll locking.229- `select.popover()` and `combobox.popover()` own popup min-width syncing because230 they know their trigger/input refs.231- `listbox` owns option scrolling and selection flash.232- `menu` owns branch closing, focus transfer, hover aim, and close animation233 sequencing.234- Checkbox primitives own native input synchronization, including235 `indeterminate`.236237When waiting for transitions or timers, use existing utilities such as238`waitForCssTransition(...)`, `wait(...)`, and `flashAttribute(...)`, then check239the component signal before dispatching events or updating more state.240241## Public Events242243Use bubbling DOM events for public component contracts, not for internal244coordination.245246Established public event pattern:247248- define a `const EVENT_NAME = 'rmx:<component>-<action>' as const`249- declare the event on `HTMLElementEventMap`250- export an event class with readonly payload fields251- dispatch from the meaningful host/root node252- export an `on<Component><Action>(handler)` mixin that wraps `on(...)`253- also support callback props such as `onValueChange` when the component is254 stateful255256Examples include `SelectChangeEvent`, `ComboboxChangeEvent`, `MenuSelectEvent`,257`AccordionChangeEvent`, `CheckboxChangeEvent`, and `CheckboxGroupChangeEvent`.258259## Styling260261Use `css(...)` descriptors and shared component tokens.262263- Prefer `componentStyleValues` for cross-component spacing, radii, surfaces,264 text colors, focus rings, and control heights.265- Reuse `shared/listbox-popover-styles.ts` for listbox-like popover surfaces,266 lists, options, labels, and indicators.267- Export style constants such as `triggerStyle`, `listStyle`, `itemStyle`, or268 `popoverStyle` when custom composition is documented.269- Keep shared CSS helpers in `src/shared` only when multiple270 components consume them.271- For simple style helpers, return readonly arrays of CSS and behavior mixins272 with precise public option types.273274## Testing And Docs275276For behavior changes, test the primitive layer directly. For styled wrappers,277test that the wrapper composes the expected primitive roles, attributes, hidden278inputs, and public events.279280Add or update:281282- `primitives.test.tsx` for provider, mixin, keyboard, focus, registration, and283 event behavior284- `index.test.tsx` for styled wrapper markup and composition285- browser tests only when DOM behavior needs a real browser286- demos for visible components; for new built-in component modules, add the287 module name to `componentDemoModules` in288 `packages/ui/demo/app/demo-runner/view.tsx` so `src/<name>/*.demo.tsx` files289 appear under the "Built-in components" heading instead of "General demos"290- `README.md` examples that import from `remix/ui/...`291- package change files when published behavior changes292293## Checklist294295Before finishing a `packages/ui` component change, verify:296297- source lives under `packages/ui/src/<name>` and exports match298 `packages/ui/package.json`299- the API layer is correct: style helper, primitive module, styled wrapper, or300 shared utility301- primitives use named exports and are consumed with namespace imports302- styled wrappers compose primitives instead of copying their behavior303- context is a plain getter/method object, not a controller or emitter layer304- host refs are registered and unregistered with the owning mixin/component305- controlled and uncontrolled state paths are both covered306- public events use the established event class plus `on...` mixin pattern307- built-in component demos are listed in the demo app's308 `componentDemoModules` allowlist309- docs, demos, tests, and change files match the public surface touched310311## Anti-Patterns312313Avoid:314315- adding new modules under `src/lib` for component public APIs316- exporting a namespace object when named exports already fit317- re-implementing `popover`, `listbox`, typeahead, outside-click, or keyboard318 helpers inside a higher-level control319- duplicating selected, active, open, or input state across layers without a clear320 owner321- using DOM events or context emitters for internal re-render signaling322- wrapping every primitive in styled markup when the lower-level primitive is the323 actual product324- placing one-off visual constants in `shared` before a second component needs325 them