Frameworks
What each framework needs that the API tables do not say.
@shineui/core/loader registers every element lazily; the browser fetches one chunk per element on the page. A per-element import is smaller still, and registers nothing until you call its defineCustomElement(). Bundlers has the setup for each bundler.
An attribute carries only a string. Anything else (option lists, menu items, table columns, chart series) is a property you assign in script; the API tables mark these property only. A boolean attribute is on while it is present and off once it is removed; never write disabled="false". Set the property, or remove the attribute.
Every value event is a sui-prefixed custom event and the payload is on event.detail. Native events stay native: a button click is click.
A field's suiChange and suiInput bubble, as the native change and input do, and so do actions such as suiPick. A report of an element's own state, such as an accordion's suiExpandedChange or a table's suiSelectionChange, does not bubble, as the native toggle does not. Listen for it on the element itself.
The default slot takes the body. Named content carries a slot attribute: slot="footer". Each component page lists its slot names in the API table.
A custom element is an unknown tag until its definition arrives. Hide them until then, or import the loader before you render.
@shineui/react holds typed React components, one per element, each importing only its own element. Install it and @shineui/core at the same version: the binding pins the core it was built from, so a range that resolves the core one patch ahead ships two copies. Vite needs no changes.
A core suiChange arrives as onSuiChange, and the payload is on event.detail. Native events are ordinary React props, so a click is onClick.
Arrays, objects and functions pass as ordinary props; the binding assigns them to the element as properties.
A ref holds the element itself, so element methods sit on it. In Vue the ref holds the Vue component instead.
Call it from an event, not from a mount effect: the element draws its inner control after React commits, so setFocus() on mount moves nothing and reports nothing. For focus on arrival, wait a frame with requestAnimationFrame.
A plain <form> collects the controls into FormData and the browser enforces required; there is no forms package to add. SuiForm adds a schema pass through standardResolver from @shineui/core. Every dismissible surface owns its open prop and fires a cancelable onSuiClose carrying the reason: escape, scrim, outside, close-button, trigger, pick or cancel; e.preventDefault() on a reason you do not accept keeps it open. The form element and the dialog have both in full.
In a server-rendered app (Next.js, Remix), a component renders nothing on the server and while the page hydrates. The element appears in the next render, so hydration finds only the markup React rendered and reports no mismatch. An app that renders only in the browser sees the element in its first render.
The elements draw into their own light DOM. Markup the browser upgraded before React hydrated it would sit among the children React owns, and React would report error 418 and rebuild the whole tree on the client. That is why the elements’ own markup does not come from the server in a React app; Server rendering covers pages without React.
The components carry "use client", so a Next.js App Router Server Component renders them directly and passes server data as props. Until the render after hydration, a ref to one is null.
@shineui/vue holds real Vue components, so there is no isCustomElement config to write. Install it and @shineui/core at the same version.
The model prop and event differ by kind: a text field commits on every keystroke through suiInput, a select once through suiChange. Every element writes its prop before it emits, so event.target.value is the committed value. A sui-slider with range holds its [low, high] pair in value too, so v-model binds it. A sui-auto-complete listens to both: suiInput for the typed text and, with multiple, suiChange for its list.
| Kind | Model prop | Event | Elements |
|---|---|---|---|
| Text | value | suiInput | sui-input-text sui-textarea sui-input-mask sui-password sui-icon-field sui-float-label sui-ifta-label sui-auto-complete (and suiChange) sui-inplace |
| Number | value | suiChange | sui-input-number sui-slider sui-rating sui-knob |
| Boolean | checked | suiChange | sui-checkbox sui-toggle-switch sui-toggle-button sui-radio-button |
| Selection | value | suiChange | sui-select sui-multi-select sui-cascade-select sui-tree-select sui-listbox sui-select-button sui-radio-group sui-checkbox-group sui-choice-cards sui-date-picker sui-time-picker sui-color-picker sui-color-editor sui-input-otp sui-input-tags sui-filter-builder |
| Rich text | html | suiChange | sui-rich-text-editor |
| Rich text, on save | html | suiSave | sui-edit-box |
| Filter set | values | suiChange | sui-filter-panel |
| Schedule | config | suiChange | sui-scheduler-builder |
Anything that is not a string or a boolean is a property and needs :, because an attribute carries only a string. The API tables mark these property only. The mistake fails quietly.
Write named content as a slot attribute on the child, not a <template #name> block. The components pass the default slot through and nothing else, so <template #footer> renders nothing, with no warning. Anything without a slot attribute lands in the body. Each API table lists the slot names.
Every dismissible surface owns its open prop and fires a cancelable suiClose before it closes, carrying the reason: escape, scrim, outside, close-button, trigger, pick or cancel. Veto the reasons you do not accept: scrim to ignore a backdrop click, pick to keep a panel open after a pick. A surface that opens from its own trigger (a select, a menu) also fires suiOpen. The element closes itself, so set your own open back to false in the handler for every close you let through; while it stays true, the next open is no change and the dialog stays shut.
A plain <form> collects the controls into FormData and the browser enforces required; there is no FormField wrapper. SuiForm adds a schema pass, places each message on the control it names and moves focus to the first failure. It holds no values.
Zod, Valibot and ArkType work through standardResolver (the Standard Schema protocol); none is a dependency. A resolver is a function of (FormData) => Record<name, message>, so any validator fits.
Custom events are sui-prefixed and reach a template kebab-cased: suiChange is @sui-change, suiClose is @sui-close. The payload is on event.detail. Native events stay native: a button click is @click.
Only a field emits suiChange. An element that is not a field reports a change of its own state through an event named for the prop: an accordion reports expanded through @sui-expanded-change, and tabs report active through @sui-active-change. A field inside a panel never sets them off, and they do not bubble, so an accordion inside a section never sets off the outer one.
A template ref holds the Vue component, not the element. The element is its $el, and in TypeScript $el is typed as that element (HTMLSuiInputTextElement for SuiInputText), so its methods (setFocus(), select(), validate()) and its state are checked. nameEl.value.setFocus() throws setFocus is not a function. React and Angular hand back the element itself.
Call the method from an event, not from onMounted: the element draws its inner control after Vue mounts, so setFocus() in onMounted moves nothing and reports nothing. For focus on arrival, wait a frame with requestAnimationFrame.
Under Nuxt, with server rendering on, a component renders nothing on the server and while the page hydrates. The element draws, with the content of its slots, once the page has mounted, so hydration reports no mismatch and no <ClientOnly> is needed. An app that renders only in the browser sees the element in its first render. Bundlers has the Nuxt setup.
The elements draw into their own light DOM, which Vue would take for nodes it rendered, so their markup does not come from the server in a Vue app. On a server-rendered page, a template ref’s $el is the element once the page has mounted.
Server rendering is a separate package. It renders the elements, not the Vue components, so a Vue page server-renders through it rather than through @shineui/vue.
@shineui/angular ships standalone directives for Angular 20, 21 and 22, with zone.js or zoneless. No CUSTOM_ELEMENTS_SCHEMA is needed. Install it and @shineui/core at the same version. Under strictTemplates inputs and outputs are typed: a wrong [variant], a wrong [columns] shape, a wrong $event.detail and a static disabled="false" are compile errors; write a boolean attribute bare or bind it.
[(ngModel)] and reactive forms work through the value accessors. A standalone component only sees directives in its own imports, so import the accessor beside the element. Without it every binding throws NG01203, "No value accessor for form control".
There are seven: TextValueAccessor for text, NumericValueAccessor for numbers, BooleanValueAccessor for checkboxes and switches, SelectValueAccessor for anything that commits a chosen value (the filter builder too), SuiHtmlValueAccessor for the rich text editors, SuiFilterPanelValueAccessor for the filter panel's values and SuiSchedulerBuilderValueAccessor for the scheduler builder's config. VALUE_ACCESSORS is all seven in one array; imports flattens it. A cleared number field gives the model null, so Validators.required fails it, and a null model gives a list-valued field an empty list.
NgForm attaches to every <form> and swallows the native submit, so the plain-form path in the form element reports nothing until the form carries ngNoForm. Use ngNoForm for suiSubmit and the browser’s validation, or Angular’s forms for Angular’s; one form cannot have both.
Every dismissible surface owns its open prop and fires a cancelable (suiClose) before it closes, carrying the reason: escape, scrim, outside, close-button, trigger, pick or cancel. Call preventDefault() on a reason you do not accept. A surface that opens from its own trigger (a select, a menu) also fires (suiOpen). The dialog has it in full.
Every element that changes its own state has an output named for the prop, carrying the new value, so [(open)], [(expanded)], [(selection)] and the rest bind both ways. On a panel it is [(collapsed)]. A close that a (suiClose) handler refuses with preventDefault() leaves the model as it was. The sui-prefixed outputs, (suiExpandedChange) and the rest, are still there.
Every directive is exported by name; a component imports the ones it uses and ships those. Importing DIRECTIVES, the array of all 146, ships all 146.
Keep hydration on, and give each of your components whose template holds ShineUI elements ngSkipHydration. The elements draw into their own light DOM as soon as they are defined, before Angular hydrates them, so hydration finds nodes it did not render and fails (NG0500 or NG0502).
With the host binding, Angular renders those components afresh on the client and hydrates the rest of the page, incremental hydration included. Put it on your component, not on a sui- element: Angular keeps that node and empties it under an element that has already drawn, which leaves it blank.
Server rendering covers the elements’ own markup on pages without Angular.
A unit test mounts the elements the way a page does. Two things decide whether that works: the DOM the runner gives you, and whether it can load an ES module package. Checked with Vitest 5 on jsdom 29 and happy-dom 20, and with Jest 30 on jsdom 26.
- jsdom renders them. Vitest with
environment: "jsdom"and Jest withtestEnvironment: "jsdom"both work; reach for jsdom first. - Both need a polyfill for form values. Neither runner lets a custom element take part in a form: jsdom's
ElementInternalshas nosetFormValue, and happy-dom has noElementInternals, so the elements put a stand-in there when they load. A form control constructs and renders either way, butFormDatacarries no ShineUI value. Addelement-internals-polyfillto the setup file, before the first ShineUI import, when a test readsFormData. - Jest has to transform the package. The packages ship ES modules and Jest's default
transformIgnorePatternsskipsnode_modules, so the first import fails: Jest 30 saysMust use import to load ES Module. Exclude@shineui,@stenciland@litfrom the pattern, or run Jest in ESM mode. Outside Jest,require()of the packages needs Node 20.19 or 22.12. Vitest needs nothing. - Wait for the element before you assert. An element renders on the frame after it registers. Await
customElements.whenDefined("sui-select")and one animation frame, or usefindByqueries.componentOnReady()exists only on elements the lazy loader registers. - What there is to mock. Nothing for construction:
ResizeObserver,IntersectionObserver,matchMediaandscrollIntoVieware guarded, so an element renders without them and does not measure. StubResizeObserverwhen a test asserts a width-dependent layout. Withoutnavigator.clipboardthe copy button and the code block fall back toexecCommand, then emitsuiCopyError.