React, Svelte and Vue
Copy stateful components into React, Svelte or Vue, connect application values and keep server-rendered markup correct.
On this page
Use native markup for stateless components and copy a framework component when the application needs to coordinate values, data, ids or a lifetime. The copied components render the same contracts in React, Svelte and Vue and attach the packaged behaviors. Start with Installation so their styles are available; copying a component does not import its CSS.
#Markup stays markup
A button is a <button> with btn btn-primary in React, Svelte and Vue alike. There is no Button component, and a project should not write one that only turns props into classes.
export function Toolbar({ saving }: { saving: boolean }) { return ( <button type="button" className="btn btn-primary" disabled={saving} aria-busy={saving}> Save changes </button> )}- There is nothing for a component to own. A button has no value to pass, no rows to render, no ids to join and nothing to attach. Its states are attributes the framework already sets.
- The checker reads classes.
master-ui checkreads.jsx,.tsx,.svelte,.vueand.astrofiles. It seesclassName="btn btn-outline"and says there is no such class; it cannot see<Button variant="outline">. - Markup needs no client script. A native element with a class renders on a server and stays there. In React Server Components it adds nothing to the client bundle.
Only the syntax differs:
| In HTML | In JSX | In Svelte and Vue |
|---|---|---|
class="btn btn-primary" | className="btn btn-primary" | The same |
for="email" | htmlFor="email" | The same |
tabindex="-1", popovertarget="share" | tabIndex={-1}, popoverTarget="share" | The same |
commandfor="confirm" with command="show-modal", interestfor="tip" | Spread them, where React's types do not list them yet | The same |
When markup repeats in a project, wrap it in a component of the project's own, in its own idiom, and keep the contract's classes inside it as literal strings.
#What a framework owns
A framework component exists when the framework owns at least one of four things:
- A value the application reads or sets. What is chosen in a combobox, whether a dialog is open.
- Rows rendered from data. The options of a listbox, the days of a month, the items of a menu.
- Ids that join parts. A label and its control, a button and the popover it opens, a tab and its panel.
- A lifetime. A behavior to attach when the element exists and detach when it goes.
That is the same test the library applies to a class: knowledge, not markup that repeats. Seventeen patterns pass it.
| Component | Renders the contract of | The framework owns |
|---|---|---|
calendar | Calendar | The value, and the month it draws |
combobox | Combobox | The value, the options |
command | Command palette | Whether it is open, the commands |
compare | Compare | The position |
dialog | Dialog | Whether it is open, the id its trigger names |
field | Field | The ids that join the label, the control, the hint and the error |
listbox | Listbox | The value, the options |
menu | Menu | The items, the id its trigger names, the target of a context menu |
otp | OTP | The code, the number of slots |
popover | Popover | Whether it is open, the id its trigger names |
splitter | Splitter | The size |
stepper | Stepper | The number |
tabs | Tabs | The selected tab, the ids that join tabs and panels |
toast | Toast | The queue: a toast() function and the region that shows it |
tooltip | Tooltip | The id that joins the control and its label |
tree | Tree | The selected row, the rows |
wheel | Wheel | The value of each column, the options |
Every other contract is markup. A select is a <select class="select">, a switch is a checkbox with role="switch", an accordion is <details>: the browser holds their state and the framework binds it as it binds any native control.
#Add a component
A component is source in your project, not an import from the package. The master-ui command copies it:
npx master-ui add combobox --framework=reactAdd combobox: ui/combobox.tsx, ui/use-behavior.ts. It renders the markup of "master-ui contract combobox" and needs that stylesheet from @master/ui.- The framework is read from
package.jsonwhen it names exactly one of React, Svelte and Vue, and is remembered inmaster-ui.jsonafter the first time. - The small helper a component imports from beside it comes with it:
use-behavior.tsfor React and Vue,behavior.svelte.tsfor Svelte. - The files go to
ui/at the root of the project.--directorynames another place, and is remembered:--directory=src/lib/uiin SvelteKit, whose source is undersrc, or--directory=app/uiin Nuxt. - Beside them,
ui/.base/keeps each file as it was taken. That copy is whatdiffcompares with upstream later; leave it alone. npx master-ui addwithout a name lists the components.- A copy that is already there is kept. It is yours, and may have been edited.
The copied component owns rendering, lifecycle and value synchronization. It imports its behavior from @master/ui/behaviors, so keyboard and focus fixes arrive when that dependency is upgraded. When the component itself changes upstream, use status and diff to review the differences and merge the changes you want into your working copy. Then run accept to record the installed package's version as the new saved base; it updates .base/ and tracking metadata without changing your working files. The registry holds the same files as react-combobox, svelte-combobox and vue-combobox.
#Use a component
Run add combobox with the framework you use, then import the copied file from its actual directory. The examples below assume the default ui/ directory beside the example file. Each shows the same combobox in its framework's own idiom. The Svelte one runs: it is rendered on the server and hydrated in the preview, with the component and the behavior a project gets. Its Code view is the file.
'use client'import { useState } from 'react'import { Combobox } from './ui/combobox'const regions = [ { value: 'hnd', label: 'Tokyo' }, { value: 'sin', label: 'Singapore' }, { value: 'fra', label: 'Frankfurt', description: 'Germany' },]export function Region() { const [region, setRegion] = useState<string | null>(null) return ( <Combobox options={regions} value={region} onValueChange={setRegion} aria-label="Region" listLabel="Regions" toggleLabel="Show regions" empty="No regions match." /> )}Region.svelte
<script lang="ts"> import Combobox from './ui/Combobox.svelte' const regions = [ { value: 'hnd', label: 'Tokyo' }, { value: 'sin', label: 'Singapore' }, { value: 'fra', label: 'Frankfurt', description: 'Germany' }, { value: 'lhr', label: 'London' }, ] let region = $state<string | null>('sin')</script><div class="grid gap-sm w-full max-w(320)"> <Combobox options={regions} bind:value={region} aria-label="Region" listLabel="Regions" toggleLabel="Show regions" empty="No regions match." /> <p class="m(0) text-sm fg-text-muted">The application holds <code class="code">{region ?? 'null'}</code></p></div><script setup lang="ts">import { ref } from 'vue'import Combobox from './ui/Combobox.vue'const regions = [ { value: 'hnd', label: 'Tokyo' }, { value: 'sin', label: 'Singapore' }, { value: 'fra', label: 'Frankfurt', description: 'Germany' },]const region = ref<string | null>(null)</script><template> <Combobox :options="regions" v-model="region" aria-label="Region" list-label="Regions" toggle-label="Show regions" empty="No regions match." /></template>Choose Tokyo and verify that the application holds hnd; the field displays the option's label. Type over that choice and the value becomes null until an option is chosen again. The model holds the option value, not arbitrary search text.
| A value | Whether it is open | Other outputs | |
|---|---|---|---|
| React | value, or defaultValue to let the component keep it, and onValueChange | open, onOpenChange | onComplete, onValueCommit, onSearch |
| Svelte | bind:value, and onValueChange | bind:open, and onOpenChange | The same callback props |
| Vue | v-model | v-model:open | @complete, @value-commit, @search |
Value ownership differs between the adapters. In React a value is the owner's word: when a reader changes the component and the owner does not take the change in onValueChange, the component goes back to the value it was given, as a controlled <input> does. The same holds for open. Svelte and Vue have no such rule. A value that is passed without bind: or v-model is where the component starts, and the component keeps what the reader chose afterwards. To refuse a change there, set the bound value back yourself.
Props that are not the component's own go to the element a reader would expect: the field of a combobox, the <dialog> of a dialog, the root of a listbox. That is how a combobox gets its aria-label, its placeholder and its name.
#The page's own markup
Add the needed component before using these examples, such as npx master-ui add dialog --framework=react or --framework=vue. A dialog, a popover, a menu and a tooltip are opened by a control that belongs to the page. The component does not render a button of its own. It hands the page the attributes that name it, and the page spreads them on its own button; the browser then opens the dialog, as it does on a plain page.
'use client'import { useState } from 'react'import { Dialog } from './ui/dialog'export function DeleteProject({ remove }: { remove: () => Promise<void> }) { const [open, setOpen] = useState(false) async function confirm() { await remove() setOpen(false) } return ( <Dialog id="delete-project" aria-labelledby="delete-project-title" open={open} onOpenChange={setOpen} trigger={attributes => <button type="button" className="btn" {...attributes}>Delete project</button>}> <header className="dialog-header"> <h2 className="dialog-title" id="delete-project-title">Delete this project?</h2> <p className="dialog-description">Atlas and its 14 deployments are removed.</p> </header> <footer className="dialog-footer"> <button type="button" className="btn" {...{ commandfor: 'delete-project', command: 'close' }}>Cancel</button> <button type="button" className="btn btn-primary" onClick={confirm}>Delete project</button> </footer> </Dialog> )}DeleteProject.svelte
<script lang="ts"> import Dialog from './ui/Dialog.svelte' let open = $state(false)</script><div class="flex flex-wrap items-center gap-sm"> <Dialog id="delete-project" aria-labelledby="delete-project-title" bind:open> {#snippet trigger(attributes)} <button type="button" class="btn" {...attributes}>Delete project</button> {/snippet} <header class="dialog-header"> <h2 class="dialog-title" id="delete-project-title">Delete this project?</h2> <p class="dialog-description">Atlas and its 14 deployments are removed.</p> </header> <footer class="dialog-footer"> <button type="button" class="btn" commandfor="delete-project" command="close">Cancel</button> <button type="button" class="btn btn-primary" onclick={() => { open = false }}>Delete project</button> </footer> </Dialog> <span class="text-sm fg-text-muted">The application knows it is {open ? 'open' : 'closed'}</span></div><script setup lang="ts">import { ref } from 'vue'import Dialog from './ui/Dialog.vue'const props = defineProps<{ remove: () => Promise<void> }>()const open = ref(false)async function confirm() { await props.remove() open.value = false}</script><template> <Dialog id="delete-project" aria-labelledby="delete-project-title" v-model:open="open"> <template #trigger="attributes"> <button type="button" class="btn" v-bind="attributes">Delete project</button> </template> <header class="dialog-header"> <h2 class="dialog-title" id="delete-project-title">Delete this project?</h2> <p class="dialog-description">Atlas and its 14 deployments are removed.</p> </header> <footer class="dialog-footer"> <button type="button" class="btn" commandfor="delete-project" command="close">Cancel</button> <button type="button" class="btn btn-primary" @click="confirm">Delete project</button> </footer> </Dialog></template>Here remove is the application's deletion function, supplied by the caller. The examples close the dialog after it resolves; add the application's busy and error states around that request. The parts inside the dialog are the contract's markup, not components: dialog-header, dialog-title and dialog-footer own nothing. Pass open only when the application decides it, to open a dialog after a request fails or to close one when a route changes. Without it, the trigger and the browser do the whole job.
A field works the same way in the other direction. It owns the ids, and gives the page's control the attributes that carry them:
import { Field } from './ui/field'export function Email({ error }: { error?: string }) { return ( <Field label="Work email" hint="We only send receipts." error={error}> {control => <input className="input" type="email" name="email" {...control} />} </Field> )}#Toasts
A toast has no element until something happens, so its component is a queue: a toast() function to call from anywhere, and a region, rendered once, that shows what is in it.
'use client'import { toast, Toaster } from './ui/toast'export function ToastExample() { return ( <> <Toaster /> <button type="button" className="btn" onClick={() => toast({ title: 'Preview ready', description: 'The build can now be reviewed.', status: 'success' })}>Show notification</button> </> )}In Svelte and Vue, toast is a named export of Toaster.svelte and Toaster.vue. A toast leaves by itself after six seconds; duration sets another time, and duration: false keeps it until it is dismissed.
#Where the logic is
For a pattern with a behavior, the adapter leaves keyboard handling and selection rules to that behavior. It has three responsibilities:
- It renders the contract's markup, with the first value already drawn in it. The first HTML is right before any script runs.
- It attaches the behavior, and attaches it again when an option changes.
- It passes the value. It writes the behavior's
valuewhen the application sets another, and reads it on everyinputandchangethat reaches the root.
After the first render the behavior moves the attributes, as it does on a plain page. Shared behavior code keeps keyboard handling in one place. Shared test scenarios check each framework adapter: mount, check the markup against the contracts, press keys, read the value that came out, set a value, unmount.
To change what a key does or what selecting means, own the behavior. A later master-ui add uses that local behavior when it creates a new component file. If you already added the component, update its import yourself; add preserves existing files.
#On a server
Rendering and behavior lifetimes
Rendering and interaction have different lifetimes. A chosen listbox value is already marked in server HTML. After mounting, the adapter attaches the behavior and synchronizes application values with it. Unmounting calls the cleanup function to stop listeners and observers; cleanup does not restore the original markup.
- The first HTML holds the state. A chosen option, a selected tab, a splitter's size, a calendar's month and its chosen day are in the markup a server sends. The marks only a browser can know, such as today in a calendar, appear when the behavior attaches.
- Give a calendar its
localewhen it is rendered on a server. The names of months and days come fromIntl, and a server and a browser do not always agree on the language to use. - In React Server Components, the components that attach a behavior or keep state are client components and say
'use client'. A file that hands one of them a function, atrigger, anonValueChangeor an item'sonSelect, is a client component too, which is why the examples above begin with'use client': a Server Component cannot pass a function to a client one.FieldandTooltiponly join ids, so they render on the server, in a file that renders nothing else from the client. Everything that is markup renders wherever it is written. - Ids come from the framework:
useIdin React and Vue,$props.id()in Svelte, so they match between a server and a browser. Vue numbers them within one application. A page that mounts several Vue applications, as islands do, gives each anapp.config.idPrefix, or two of them will name different elementsv-0.
#A component of your own
Use the helper copied by master-ui add when you need different rendering around the same behavior. For example, add listbox for the selected framework to obtain its helper, then write a Sizes component beside the ui/ directory, as the imports below assume. Render the initial choice into the markup so server HTML is correct, and let the helper synchronize later values. In React the helper is a hook that returns a ref:
'use client'import { useState } from 'react'import { listbox } from '@master/ui/behaviors/listbox.js'import { useBehavior } from './ui/use-behavior'export function Sizes({ value, onValueChange }: { value: string, onValueChange: (value: string | string[] | null) => void }) { const ref = useBehavior(listbox, undefined, { value, onValueChange }) const [first] = useState(() => value) return ( <div className="listbox" role="listbox" aria-label="Size" tabIndex={0} ref={ref}> {['Small', 'Medium', 'Large'].map(size => ( <div className="listbox-option" role="option" aria-checked={size === first} key={size}>{size}</div> ))} </div> )}Its second argument is the behavior's options, its third the value and what hears it. In Svelte the helper makes an attachment, and in Vue it is a composable that takes a template ref and a model:
<script lang="ts"> import { untrack } from 'svelte' import { listbox } from '@master/ui/behaviors/listbox.js' import { behavior } from './ui/behavior.svelte.js' let { value = $bindable() }: { value?: string | string[] | null } = $props() const first = untrack(() => [value ?? []].flat()) const attach = behavior(listbox, undefined, { value: () => value, change: next => { value = next } })</script><div class="listbox" role="listbox" aria-label="Size" tabindex="0" {@attach attach}> {#each ['Small', 'Medium', 'Large'] as size} <!-- The behavior writes aria-selected on the active option. --> <!-- svelte-ignore a11y_role_has_required_aria_props --> <div class="listbox-option" role="option" aria-checked={first.includes(size)}>{size}</div> {/each}</div><script setup lang="ts">import { useTemplateRef } from 'vue'import { listbox } from '@master/ui/behaviors/listbox.js'import { useBehavior } from './ui/use-behavior'const value = defineModel<string | string[] | null>()const first = [value.value ?? []].flat()useBehavior(useTemplateRef<HTMLElement>('root'), listbox, undefined, value)</script><template> <div class="listbox" role="listbox" aria-label="Size" tabindex="0" ref="root"> <div v-for="size in ['Small', 'Medium', 'Large']" :key="size" class="listbox-option" role="option" :aria-checked="first.includes(size)">{{ size }}</div> </div></template>Behaviors shows the plain way to attach one in each framework, without the helper.
#Other headless libraries
The shipped components use the browser platform and Master UI's behaviors. They take no dependency on a headless component library.
If the application needs another library's behavior, place the contract's classes on its corresponding elements and check every state, keyboard action and focus transition against the contract. An adapter that writes different attributes may need an owned stylesheet. CSS classes alone do not establish compatibility; start with one complete interaction before applying the integration throughout the project.