Add a component to your app
Install @markless/ui, add its ui() plugin to vite.config.ts, and write a family's parts as tags.
You saw a modal as a stack of parts. Now put a family in your own app.
@markless/ui ships its .tsrx source. The Markless compiler builds it together with your own components, so the same rules apply to both.
Install and add the plugin
npm install @markless/uipnpm add @markless/uiyarn add @markless/uibun add @markless/uinub add @markless/uiaube add @markless/uiimport { defineConfig } from 'vite';
import { markless } from '@markless/core/vite';
import { ui } from '@markless/ui/vite';
export default defineConfig({
plugins: [ui(), markless()],
});
ui() turns icon tags, such as <lucide.check />, into inline SVG. It also tells Vite to leave the package to the Markless compiler. Icon packs such as lucide import from @markless/ui too. In a multi-page app built with the router, the list is [ui(), markless(), router()].
The compiler plans each family’s updates before your app runs, like your own components. Then the same family renders wherever your app does: in the browser alone, on a server, or in a test. The package’s own tests render each family in the browser alone and from server HTML.
Pick a place to render the select. Then open it.
Does a select change when it renders somewhere else?
Pick a place to render. Then open Choose a fruit and pick one.
You picked:
Same select from every choice.
- select.rootui-closed
- select.labelno ui-* attribute
- select.triggerui-closed
- select.contentui-closedhidden
- select.itemAppleno ui-* attribute
- select.itemindicatorui-hidden
- select.itemCherryno ui-* attribute
- select.itemindicatorui-hidden
- noteBefore your app ran, the compiler planned which attributes read open and which read the choice.
- ranBuilt the select once, in the browser.
Simplified. The look comes from a few CSS rules of our own. The parts list leaves out the item label parts and the hidden form field. The package's own tests build every family both in the browser alone and from server HTML.
Notice that the parts and their ui-* attributes are the same in every place.
Write the parts as tags
Import a family by name. Each part is a tag on that name.
import { state } from '@markless/core';
import { select } from '@markless/ui';
export default function FruitPicker() @{
let chosen = state('');
<form>
<select.root name="fruit" onChange={(value: string) => { chosen = value; }}>
<select.label>Favorite fruit</select.label>
<select.trigger>Choose a fruit</select.trigger>
<select.field />
<select.content>
<select.item value="apple">
<select.itemlabel>Apple</select.itemlabel>
<select.itemindicator>Chosen</select.itemindicator>
</select.item>
<select.item value="cherry">
<select.itemlabel>Cherry</select.itemlabel>
<select.itemindicator>Chosen</select.itemindicator>
</select.item>
</select.content>
</select.root>
<p>You picked: {chosen}</p>
</form>
}
onChange gets the new value. select.field is the element the form submits, under the root’s name.
Open a modal from your own state
A modal takes one modal.trigger. To open it from somewhere else, pass open from your state.
import { state } from '@markless/core';
import { modal } from '@markless/ui';
export default function Session() @{
let isOpen = state(false);
<section>
<button type="button" onClick={() => { isOpen = true; }}>Open</button>
<modal.root open={isOpen} onChange={(next: boolean) => { isOpen = next; }}>
<modal.backdrop>
<modal.content>
<modal.title>Session expired</modal.title>
<modal.close>Sign in again</modal.close>
</modal.content>
</modal.backdrop>
</modal.root>
</section>
}
When Escape or Sign in again closes the modal, onChange sets isOpen back to false.
Next: Your select works, but it looks like plain HTML. How do you give it a look? Styling components →
