Integration FAQ
Common questions and pitfalls from real-world integrations.
Composition patterns (React migration view)
Where are asChild / Slot / Portal equivalents here?
The asChild / Slot / Portal concepts from the React ecosystem solve component-composition problems that grew out of the React model (eliminating library-imposed wrapper elements, mounting content elsewhere). Under Web Components those premises either do not exist or already have equivalents:
- Swapping the root element (the wrapper-elimination job of asChild / Slot) — a Web Component itself (its host) is a real element in the DOM, so external layouts and selectors act on it directly; there is no library-imposed wrapper to strip. When a component must render as a different semantic element (e.g. a button that behaves as a link), use attribute-driven root switching: when
hrefis present, buttons/tags render a native<a>internally and keep keyboard & disabled semantics (see the oas-button / oas-tag docs). - Using an arbitrary element as a trigger (e.g. the common
Trigger asChildcomposition) — the equivalent is the default slot + a trigger attribute: overlay components treat the default slot's first child as the trigger (any element / component), and atriggerattribute picks the mode (click/hover/contextmenu, …); panel content goes through thecontentattribute or named slots (title/header/content/footer, …). Events and ARIA are wired up internally by the component. - Mounting content into
bodyor another container (Portal / Teleport) — the equivalent is theappend-tosystem: overlay/pinned components such as tooltip / popover / modal / drawer acceptappend-to="body"to move the overlay into a target container while preserving style scoping and stacking contexts; removing it returns the node and leaves no orphaned elements.
In short: the composition trio under Web Components is "attribute-driven root element + named slots + append-to", which covers the scenarios asChild / Slot / Portal solve — no same-named API is needed when migrating.
React / Vue: what if an attribute name collides with a native DOM property?
When passing attributes to a custom element, if the name matches an existing DOM property on the element, the host writes it as a property (el[name] = value) instead of setAttribute — React 19 does this, and Vue's bound syntax (:name) hits the same rule for custom elements (source: key in el; except spellcheck / draggable / translate / autocorrect, which always go through attributes).
How OAS-UI handles it:
prefix/suffix/blur:prefixcollides with the read-onlyElement.prefix, andblur(oas-backdrop) with theHTMLElement.blur()method;suffixhas no DOM counterpart and is handled symmetrically. The relevant components (input / input-number / statistic / countdown / tree-select / backdrop / mentions) shadow these names with property accessors mapped to the canonical attributesprefix-text/suffix-text/blur/trigger(mentions'prefixis a legacy alias oftrigger) — so they work through both channels: React'sprefix="¥"/blur="blur(8px)", Vue's static/bound syntax, and plain HTML all behave.prefix-text/suffix-textare the canonical names andprefix/suffixare compatibility aliases; shadowingblurreplaces that element's nativeblur()method with the attribute accessor (no practical impact on a backdrop). Under TypeScript,blurstill shows the inherited method type (methods can't be declaration-merged), so to assign it as a property use the attribute orsetAttributeinstead.- Other writable colliding attributes (
title,dir,role,hidden,draggable,spellcheck,autofocus,id, …) are writable DOM properties, so React / Vue writing them as properties reflects back to attributes normally; notetitlealso fires the browser's native tooltip. - If you build your own custom element, avoid attribute names that match read-only
Element/HTMLElementproperties or methods (e.g.prefix,blur) — or shadow them with accessors the same way.
Events
Why do all events carry the oas- prefix?
oas- events are emitted through the core's shared emit(), which explicitly sets bubbles + composed so they escape the Shadow DOM to window (note: the platform's native CustomEvent defaults both to false — the escape is a library convention, not a platform default). Unprefixed change / select would collide with native events (e.g. the text-selection select event) and host-framework synthetic events, making debugging painful. The prefix makes the origin unambiguous — listen for oas-change, not change.
Each component's docs list its events and when they fire (see the "Events" section of the component docs).
Difference between oas-input and oas-change?
Same as native semantics: oas-input fires during continuous interaction (e.g. every frame while dragging a slider); oas-change fires on commit (release / Enter / blur). For low-frequency work such as auto-save, listen to oas-change to avoid writing on every pixel of a drag.
How do I read controlled state?
Controlled components (switch / radio-group / checkbox-group / slider / input-number, etc.) write the latest value back to the host attribute after interaction: el.getAttribute('value') / el.hasAttribute('checked') read the current state directly, consistent with the event detail — no caching needed on your side.
Style customization
No attribute selectors after ::part()
Per the CSS specification, the ::part() pseudo-element can only be followed by pseudo-classes (:hover / :focus, etc.), never attribute selectors. Writing ::part(item)[aria-expanded='true'] makes the entire rule get silently dropped by the browser — including other comma-separated selectors in the same rule — with no error whatsoever.
/* correct */
#menu::part(item):hover {
/* ... */
}
/* wrong: the whole rule dies (comma-separated siblings die with it) */
#menu::part(item):hover,
#menu::part(item)[aria-expanded='true'] {
/* ... */
}Alternatives for attribute-based states: components usually mirror state to a CSS class or expose a dedicated ::part(); prefer those. As a last resort, operate on shadowRoot from JS.
::part() cannot reach shadow-internal descendants
::part() only matches elements the component explicitly exposes; you cannot select their descendants (::part(item) .check does not work). To customize internals, look for deeper exposed parts, or pierce with CSS custom properties (each component's "Style customization" section lists the available variables).
How do I make my own CSS follow light/dark themes?
Reference theme variables: background: var(--oas-color-bg), color: var(--oas-color-text-primary). When data-theme switches, the library variables swap automatically and anything referencing them follows. See Design Tokens for the full list.
Internationalization (i18n)
What is the placeholder syntax for translation interpolation?
The translation-interpolation placeholder in @oas-ui/i18n is {name} — a single pair of braces with no # prefix. Write {count} in the language-pack template and pass t(key, { count }) to substitute, e.g. 'pagination.total': '{total} items' + t('pagination.total', { total: 42 }) → 42 items.
#{name}is not a valid placeholder: the#is never consumed by the interpolation logic and stays verbatim in the output (after substitution it is still#{42}). Do not carry over#{var}-style templating habits when writing language packs.
How do I register a custom language pack / texts?
@oas-ui/i18n provides a global locale registry: registerLocale(locale) registers a custom language pack and setLocale(name) switches globally; the pack shape follows the built-in zh-CN (the key set is fully typed, missing keys are compile-time errors). oas-config-provider supports local injection (the locale attribute): components inside it prefer the injected locale for built-in texts without any global setup.
For languages beyond the built-in 10: use the zh-CN pack's messages as a template → spread ...zhCN.messages to inherit all keys and replace them one by one → tag messages as a Locale (type guardrail: missing keys fail at compile time) → mark dir: 'rtl' for RTL languages → registerLocale → setLocale. See the @oas-ui/i18n package README, "Custom locale packs" section, for the full step-by-step example. Ship custom packs with your own dynamic import() for code splitting (loadLocale() only maps the 10 built-in names).
Which languages are built in? Can they be loaded on demand?
Ten locales ship built in: zh-CN (default), en, ja, ko, de, fr, es, pt, ru, ar (RTL). The main entry only carries zh-CN; the rest are loaded on demand and never enter the initial bundle:
import { loadLocale, setLocale } from '@oas-ui/i18n'
await loadLocale('ja') // dynamic import('@oas-ui/i18n/ja'), a separate chunk
setLocale('ja')You can also import a pack statically (tree-shaking: only what you use ships):
import ja from '@oas-ui/i18n/ja'
setLocale(ja)Date and number formatting go through native Intl (Intl.DateTimeFormat / Intl.NumberFormat), so these locales display correctly with no extra translations; the default first-day-of-week is derived from the locale too (Monday for European/Chinese, Sunday for Japanese/Korean/English/Arabic).
How are RTL locales handled?
The ar pack is tagged dir: 'rtl', so switching to ar mirrors floating-layer positioning automatically (select / combobox / auto-complete / tree-select / date-picker / time-picker / mentions, …); you can also enable it explicitly or locally with oas-config-provider direction="rtl". Direction is resolved as config-provider > nearest ancestor dir > document.dir > current locale.
Theming
The page body stays white after switching to dark?
The body is outside any component's Shadow DOM and is never styled automatically. Reference the variables explicitly:
body {
background: var(--oas-color-bg);
color: var(--oas-color-text-primary);
}