Skip to content
ShineUI
Install
ShineUI / Frameworks

Frameworks

What each framework needs that the API tables do not say.

Registering the elements

@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.

// everything, fetched per element as it appears import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); // or just what this page needs, registered one by one import { defineCustomElement } from "@shineui/core/components/sui-button.js"; defineCustomElement();
Properties, not attributes

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.

const select = document.querySelector("sui-select"); select.options = ["Viewer", "Editor", "Admin"]; // property select.setAttribute("label", "Role"); // attribute is fine, it is a string
Events

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.

Slots

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.

Flash of undefined content

A custom element is an unknown tag until its definition arrives. Hide them until then, or import the loader before you render.

sui-button:not(:defined) { visibility: hidden; }
There is nothing to configure

@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.

npm i @shineui/react @shineui/core
// main.tsx import "@shineui/core/shineui.css"; import "@shineui/core/shineui-fonts.css"; // optional
Events are props

A core suiChange arrives as onSuiChange, and the payload is on event.detail. Native events are ordinary React props, so a click is onClick.

import { SuiSelect } from "@shineui/react"; export function RolePicker() { return ( <SuiSelect label="Role" options={["Viewer", "Editor", "Admin"]} // arrays pass as props onSuiChange={(e) => save(e.detail)} /> ); }
Complex data passes as props

Arrays, objects and functions pass as ordinary props; the binding assigns them to the element as properties.

A ref is the element

A ref holds the element itself, so element methods sit on it. In Vue the ref holds the Vue component instead.

const field = useRef(null); // ... field.current.setFocus();

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.

Forms and open state

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.

Server rendering

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.

// app/page.tsx, a Server Component import { SuiButton, SuiSelect } from "@shineui/react"; export default async function Page() { const roles = await loadRoles(); return ( <main> <h1>Team</h1> <SuiSelect label="Role" options={roles} /> <SuiButton variant="accent" label="Invite" /> </main> ); }

@shineui/vue holds real Vue components, so there is no isCustomElement config to write. Install it and @shineui/core at the same version.

v-model

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.

KindModel propEventElements
TextvaluesuiInputsui-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
NumbervaluesuiChangesui-input-number sui-slider sui-rating sui-knob
BooleancheckedsuiChangesui-checkbox sui-toggle-switch sui-toggle-button sui-radio-button
SelectionvaluesuiChangesui-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 texthtmlsuiChangesui-rich-text-editor
Rich text, on savehtmlsuiSavesui-edit-box
Filter setvaluessuiChangesui-filter-panel
ScheduleconfigsuiChangesui-scheduler-builder
<script setup lang="ts"> import { ref } from "vue"; import { SuiSelect, SuiInputText } from "@shineui/vue"; const customer = ref(""); const basis = ref("accrual"); </script> <template> <SuiInputText label="Customer" name="customer" v-model="customer" /> <SuiSelect label="Basis" name="basis" v-model="basis" :options="['accrual', 'cash']" /> </template>
Properties, not attributes

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.

<!-- wrong: an attribute can only carry a string, so the element receives "[object Object]" --> <SuiMenu items="rows" /> <!-- right --> <SuiMenu :items="rows" />
Named slots

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.

<!-- wrong: the footer never renders, and nothing says so --> <SuiCard heading="Invoice 1042"> <p>Net 30, due 4 April.</p> <template #footer><span>Sent 4 March</span></template> </SuiCard> <!-- right: the slot name goes on the child --> <SuiCard heading="Invoice 1042"> <p>Net 30, due 4 April.</p> <span slot="footer">Sent 4 March</span> </SuiCard>
Open state

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.

<script setup lang="ts"> import { ref } from "vue"; import { SuiButton, SuiDialog } from "@shineui/vue"; const open = ref(false); const dirty = ref(true); /* A cancelable close is how you ask "are you sure?", with no extra props. A close you let through sets the model back to false, or the next open changes nothing. */ function onClose(e: CustomEvent<string>) { if (e.detail === "scrim" && dirty.value) { e.preventDefault(); return; } open.value = false; } </script> <template> <SuiButton label="Record payment" @click="open = true" /> <SuiDialog heading="Record payment" :open="open" @sui-close="onClose" /> </template>
Forms

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.

<script setup lang="ts"> import { z } from "zod"; import { standardResolver } from "@shineui/core"; import { SuiForm, SuiInputText, SuiInputNumber, SuiButton } from "@shineui/vue"; const resolver = standardResolver(z.object({ customer: z.string().min(1, "Choose the customer this invoice bills."), amount: z.coerce.number().positive("Enter an amount above zero."), })); function save(e: CustomEvent<FormData>) { e.preventDefault(); // keep the page, post it yourself fetch("/invoices", { method: "POST", body: e.detail }); } </script> <template> <SuiForm :resolver="resolver" @sui-submit="save"> <SuiInputText label="Customer" name="customer" /> <SuiInputNumber label="Amount" name="amount" /> <SuiButton type="submit" variant="brand" label="Save invoice" /> </SuiForm> </template>

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.

Events

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.

Refs and element methods

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.

<script setup lang="ts"> import { useTemplateRef } from "vue"; import { SuiInputText, SuiButton } from "@shineui/vue"; const nameEl = useTemplateRef<InstanceType<typeof SuiInputText>>("nameEl"); /* The ref holds the Vue component. $el is the element, typed. */ function focusCustomer() { nameEl.value?.$el.setFocus(); } </script> <template> <SuiInputText ref="nameEl" label="Customer" name="customer" /> <SuiButton label="Fix the customer" @click="focusCustomer" /> </template>

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.

Nuxt and server rendering

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.

npm i @shineui/angular @shineui/core
Import the value accessor beside the element

[(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".

import { Component } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { SuiSelect, SelectValueAccessor } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiSelect, SelectValueAccessor, FormsModule], template: `<sui-select label="Role" [options]="roles" [(ngModel)]="role"></sui-select>`, }) export class RolePicker { roles = ["Viewer", "Editor", "Admin"]; role = "Editor"; }

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 and the plain-form path

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.

Open state

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.

Two-way binding

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.

<sui-dialog heading="Record payment" [(open)]="paying" (suiClose)="confirmLeave($event)"> <p>Paid in full.</p> </sui-dialog> <sui-accordion [(expanded)]="sections">...</sui-accordion> <sui-panel heading="Notes" collapsible [(collapsed)]="notesClosed">...</sui-panel>
Import by name, ship by name

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.

Server rendering and hydration

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.

@Component({ selector: "app-billing", imports: [SuiButton, SuiCard], host: { ngSkipHydration: "true" }, template: `<sui-card heading="Invoice 1042">...</sui-card>`, }) export class Billing {}

Server rendering covers the elements’ own markup on pages without Angular.

Testing

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 with testEnvironment: "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 ElementInternals has no setFormValue, and happy-dom has no ElementInternals, so the elements put a stand-in there when they load. A form control constructs and renders either way, but FormData carries no ShineUI value. Add element-internals-polyfill to the setup file, before the first ShineUI import, when a test reads FormData.
  • Jest has to transform the package. The packages ship ES modules and Jest's default transformIgnorePatterns skips node_modules, so the first import fails: Jest 30 says Must use import to load ES Module. Exclude @shineui, @stencil and @lit from 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 use findBy queries. componentOnReady() exists only on elements the lazy loader registers.
  • What there is to mock. Nothing for construction: ResizeObserver, IntersectionObserver, matchMedia and scrollIntoView are guarded, so an element renders without them and does not measure. Stub ResizeObserver when a test asserts a width-dependent layout. Without navigator.clipboard the copy button and the code block fall back to execCommand, then emit suiCopyError.
// vitest.config.ts export default { test: { environment: "jsdom", setupFiles: ["./test/setup.ts"] } }; // test/setup.ts, when a test reads FormData import "element-internals-polyfill"; // jest.config.js module.exports = { testEnvironment: "jsdom", transformIgnorePatterns: ["node_modules/(?!(@shineui|@stencil|@lit)/)"], };