Overview
UI components
@lumiose/ui: accessible, unstyled UI primitives built on native elements (<dialog>, popover, invoker commands, CSS anchor positioning), server-rendered without hydration. Style them with your own CSS or the optional default theme.
Status: in development. @lumiose/ui is on npm and keeps its own 0.x versions, apart from
the version all other Lumiose packages share. The goal for its 1.0 is parity with Base UI's components; the
table below lists them as they land.
These pages ship in the package, in node_modules/@lumiose/ui/docs, so they match the installed
version, and the Lumiose site renders them with a live demo of each example at
lumiose.dev/ui. Start with Installation
and Theming. For coding agents, llms.txt indexes them and llms-full.txt holds them all;
lumiose docs ui and lumiose docs dialog print them; the MCP server's ui_components tool lists
every component's parts, props, events and styling hooks; and lumiose agents init adds a UI
section to AGENTS.md and the lumiose-ui skill. Every component page has the same sections:
Anatomy, API, Example, Styling, Keyboard and focus, Without JavaScript, Browser support and Related.
#Approach
- Native first. A component renders the element the platform already has for the job: a
<dialog>opened by an invoker command, a popover, a<button>. Those work in server HTML before any script loads, and keep the browser's own keyboard handling and focus management. - Custom elements only where the platform falls short. Keyboard navigation in a menu or the
filtering of a combobox need script: those components render a light-DOM custom element with a
lu-tag (lu-menu,lu-combobox) that upgrades the server HTML in place. A page is meant to load only the definitions of the elements it renders, and no island runtime for them (in development, with those components). - Small scripts for native gaps. Where a native element falls short of Base UI's behaviour, a small script closes the gap, such as Dialog's right-click and touch handling. The component still works without it; a page that renders the component loads it once, without the island runtime.
- Parts. Each component is a set of parts named as in Base UI (
Dialog.Root,Dialog.Trigger,Dialog.Popup, …). Every part that renders an element takes that element's props (class,style,aria-*, events) and passes them through. A part finds itsRootthrough context, so aRootand its parts render on the same side of an island boundary, even where the JSX nests them. A part the page passes to an island as a slot (children or a JSX prop) renders in the page, outside theRootthe island renders, and the server throws "Combobox parts must be inside <Combobox.Root>"; an island inside aRootthe page renders does not see thatRooteither. Pass the data as props and render theRootand its parts inside the island (Islands). - Signals. A component's state is a prop and a callback:
openwithonOpenChange,checkedwithonCheckedChange,valuewithonValueChange. Pass a signal's value and write it back in the callback to keep both in sync, or leave both out and the element keeps its own state. Custom elements report changes with lowercase DOM events, like native ones:openchange,checkedchangeandchange. The callbacks run insidebatch(), as event handlers do: what they write renders together, and the DOM is current as soon as the event returns. - Keyboard contracts. Every component follows its WAI-ARIA Authoring Practices pattern, and its end-to-end tests check the keyboard paths.
- WCAG 2.2 AA. Every component meets it: the components own semantics, keyboard and behaviour, and the default theme owns the visual criteria (contrast, non-text contrast, focus visible, target size). Each component's end-to-end tests run axe with the WCAG A and AA rules, closed and open, in light and dark, and check reflow at 320 CSS pixels and text spacing.
This is the approach of Timeless, ported to Lumiose.
#Styling
@lumiose/ui is unstyled: its stylesheets hold only what a component needs to work (display,
positioning and anchoring, the top layer, the CSS variables a component sets), in the lu.core
cascade layer. A look is opt-in: the default theme, or your own CSS. Installation
shows the imports, and Theming the layers, the theme's classes and custom properties,
and how to style the parts yourself.
#The default theme
@lumiose/ui/themes/default.css is a ready look for demos, prototypes and getting started: neutral
surfaces, a blue primary, light and dark. Its classes (lu-button, lu-dialog, …) and its --lu-
custom properties, named as in shadcn/ui, are listed in Theming.
#Components
| Component | Status |
|---|---|
| Dialog, alert dialog | Available: native <dialog>, works without JavaScript |
| Popover | Available: native popover, works without JavaScript |
| Tooltip | Available: native hint popover, opened by its script |
| Menu | Available: popover menu, opens without JavaScript |
| Combobox | Available: <lu-combobox>, a form control that filters its options |
| Select | Available: native <select>, customizable where supported, no script but for labels |
| Tabs | Available: server HTML with the full state, <lu-tabs> adds the keys |
| Field | Available: label, description, errors; native validation in the field |
| Fieldset | Available: native <fieldset> and <legend>, no script |
| Form | Available: native <form>, a form action's errors in its fields |
| Input | Available: native <input>, no script |
| Checkbox | Available: native checkbox, indeterminate and read-only |
| Checkbox Group | Available: checkboxes with one value and a parent checkbox |
| Radio | Available: native radios in a radiogroup fieldset |
| Switch | Available: native checkbox with the switch role |
| Collapsible | Available: native <details>, works without JavaScript |
| Accordion | Available: native <details> items, <lu-accordion> adds the keys |
| Toast | Available: server-rendered or from a manager, <lu-toast-viewport> times and closes them |
| Button | Available: native <button>, focusable when disabled |
| Toggle | Available: <button aria-pressed>, pressed by the component in an island |
| Toggle Group | Available: <lu-toggle-group> presses its toggles and moves focus |
| Progress | Available: native <progress>, indeterminate when unknown, no script |
| Meter | Available: native <meter>, no script |
| Slider | Available: native range inputs, <lu-slider> adds ranges and Page Up and Page Down |
| Number Field | Available: native number input, or a formatted spinbutton; press-and-hold buttons |
| Autocomplete | Available: free text with suggestions, on Combobox's <lu-combobox> |
| OTP Field | Available: one native input under visual slots; autofill, paste, validation natively |
| Preview Card | Available: a link whose hover or focus opens a card; the link works without JavaScript |
| Toolbar | Available: <lu-toolbar>, one tab stop across buttons, links, inputs and toggles |
| Scroll Area | Available: native scrolling, custom scrollbars from <lu-scroll-area> |
| Context Menu | Available: Menu at the pointer, opened by its script (right-click, long press, keys) |
| Drawer | Available: a Dialog against an edge, works without JavaScript; its script adds the swipe |
| Separator | Available: native <hr>, horizontal or vertical, no script |
| Avatar | Available: an image over its fallback, <lu-avatar> follows its loading status |
| Menubar | Available: Menus in a role="menubar", <lu-menubar> moves between them |
| Navigation Menu | Available: a <nav> whose triggers open panels of links, works without JavaScript |
| Virtual List | Available: a <ul> that renders the rows near the viewport, in islands |
| Virtual Grid | Available: cells in auto-fill columns, fixed or measured rows, in islands |
| Virtual Table | Available: table rows between spacer rows, with aria-rowcount and aria-rowindex |
| Virtualizer | Available: createVirtualizer(), the headless primitive under the three, in islands |
#Ids
The parts connect themselves by id (for, aria-labelledby, aria-controls, popovertarget,
commandfor), which they generate. A server renders a page once, in order: a part can take an
id of yours only where nothing before it in the page points at it, and the parts after it then
point at yours.
- Your
idnames it: the triggers of Accordion, Collapsible, Menu (and submenus) and Navigation Menu; Tabs' tabs; Combobox'sLabelandItems; theTitleandDescriptionof a Dialog, Drawer or Popover (the popup renders them before its own attributes); a Toast'sRoot; theLabelof a Slider, Progress or Meter; and the elements that nothing points at (a Slider'sRoot, a Tabs or AccordionRoot, a Toolbar, a Tooltip's trigger). - It keeps its generated id, because a part before it points at it: the popups of Dialog,
Drawer, Popover, Tooltip, Preview Card and Menu; Navigation Menu's
Content; the panels of Tabs, Accordion and Collapsible; Combobox'sInput,ListandPopup; a Slider's thumbs and the tracks of Progress and Meter (theirLabelpoints at them); a Field's control (Field.Label). Combobox's and Field'sRootkeep theirs too: their scripts find the parts by it. A Number Field or an OTP Field takes the input'sidon itsRoot.
To reach a popup or a panel from elsewhere (a commandfor, a #hash link), point at a part that
takes your id, such as a tab, or read the generated id from the rendered page.
#Right to left
There is no direction provider: every component reads the direction from the page, the dir
attribute on <html> or on any container around it, when it acts (TASK-385). Right to left:
- Arrow keys that move along a row swap: Tabs, Toggle Group, Toolbar, Menubar, Navigation Menu, Accordion's and Menu's submenu keys, a Radio group (the browser's), Slider and Number Field's scrub area (leftward goes up).
- Placement follows logical sides and alignment:
inline-startandinline-endsides, andalign="start"or"end", mirror;leftandrightsides stay physical, as in Base UI. - Sliders and scrollbars run from the right: a Slider's track and Indicator, a Scroll Area's horizontal thumb, a Progress or Meter bar (the indeterminate segment slides leftward).
- Swipes are physical: a Toast or a Drawer swiped right goes right in either direction.
- The theme uses logical properties (
inset-inline-*,margin-inline-*,border-inline-*), so it mirrors with no extra rule. A numeric OTP Field keeps its slots left to right, as digits read in right-to-left scripts too.
The theme avoids :dir(), so it also mirrors in a project whose own build.cssTarget predates
:dir(): Lightning CSS, which minifies production CSS, then lowers it to a list of :lang()
selectors that misses a dir attribute. It uses [dir="rtl"] around the component instead, or
logical properties.
#Localisation
Labels and messages come from your markup, with four English defaults to override for another language (TASK-456):
| Default | Where | Override |
|---|---|---|
| "Notifications (F6)" | Toast.Viewport, the landmark's name |
aria-label on Toast.Viewport |
| "Remove" | Combobox.Chips, before each chip's label |
removeLabel on Combobox.Chips |
| "Increase", "Decrease" | Number Field's step buttons | aria-label on each button |
Validation messages are the browser's, in its language; a Field's validate and a form action's
errors are yours.
#Size
@lumiose/ui is opt-in, so its size is reported apart from the runtime's caps:
pnpm --filter @lumiose/ui size prints, per component, the JavaScript a server-rendered page loads
for its custom elements, the JavaScript an island that uses it loads, and its CSS (minified, gzip
and brotli). The scripts load as separate modules, in islands too: each component's script, and
the modules several scripts share (the dismissal model, the placement fallback, the hover timing),
each a file of its own, as an app's build splits them. The sizes on the component pages are those
files' brotli sizes added up.
#Related
- Web components: custom elements in Lumiose pages and islands.
- Islands: where components run in the browser.
- JSX:
class, events andon:.