Web Components
Every kit and template as custom elements, for Vue, Svelte, Solid, Angular and plain HTML.
UISet's skins, tokens and base styles dress UI SDK's own custom elements, the same 55
components the React registry wraps, because both carry the same data-slot attributes.
So a page in plain HTML, Vue, Svelte or Solid gets UISet's look and themes from one
stylesheet, and UISet's own primitives register as <uiset-*> elements.
pnpm web:build # makes web/dist/uiset.js and web/dist/uiset.css
The build keeps UI SDK as a peer: install @ui-sdk/web and @ui-sdk/core (0.1.0-beta.2)
and import the UI SDK components you use. uiset.css already includes UI SDK's web.css.
uiset.js is about 117 KB gzipped with every kit and uiset.css 39 KB; the chat's
markdown parser (53 KB) loads only when a chat first draws an answer. The page templates
aren't in the build: they're pages to copy, each with its own small script. Install shiki too for colored code in chats;
without it, code blocks are plain.
Plain HTML
<link rel="stylesheet" href="/uiset/uiset.css" />
<script type="module">
import "@ui-sdk/web/button"
import "@ui-sdk/web/switch"
import "@ui-sdk/web/tabs"
import "/uiset/uiset.js"
</script>
<uiset-root skin="studio" theme="dark">
<ui-button><button>Save</button></ui-button>
<ui-button><button data-variant="outline">Cancel</button></ui-button>
<label><ui-switch><ui-switch-thumb></ui-switch-thumb></ui-switch> Email me</label>
<span id="cutoff">Cutoff</span>
<uiset-knob aria-labelledby="cutoff" default-value="40">
<uiset-knob-indicator></uiset-knob-indicator>
<uiset-knob-value></uiset-knob-value>
</uiset-knob>
</uiset-root>
<uiset-root>is the React<UISet>:skin(base,blueprint,studio),theme(light,dark, or leave it out to follow the system),sound(present to turn the feedback sounds on) andhaptics="off". Change the attributes to switch live.- Variants go on the element that gets
data-slot, which for a wrapping element like<ui-button>is the native button inside it:<button data-variant="outline">. - UISet's primitives fire their callbacks as events on the root element, like UI SDK's:
knob.addEventListener("valuechange", (e) => e.detail.value).
UISet's elements
| Element | What it is |
|---|---|
<uiset-root> | The skin, theme and feedback root |
<uiset-knob>, <uiset-knob-indicator>, <uiset-knob-value> | The Studio knob: drag, arrows, Home/End; the readout follows the value with no script |
<uiset-patch-bay>, <uiset-patch-bay-jack> | The Studio patch bay: drag a cable, or Enter, arrows, Enter; draws its own cables |
<uiset-swipe-list> | Mobile swipe rows, from items (or your own <uiset-swipe-row> markup) |
<uiset-wheel> | A scroll wheel around a <ui-listbox class="wheel-list"> |
<uiset-pull-refresh> | Pull down at the top to refresh what's inside it |
<uiset-board> | A kanban board, from columns, cards and lanes |
<uiset-schedule>, <uiset-schedule-month> | A week, a day or a timeline from tracks and events, and the month at a glance |
<uiset-canvas>, <uiset-canvas-toolbar>, <uiset-canvas-inspector> | A node editor from nodes and edges, its toolbar, and the selected node's details |
<uiset-file-tree>, <uiset-editor-tabs>, <uiset-code-editor>, <uiset-command-palette>, <uiset-problems>, <uiset-status-bar> | The IDE's parts: files, open tabs, a code editor, the palette, problems and the status bar |
<uiset-agent-console>, <uiset-agent-step>, <uiset-agent-approvals>, <uiset-agent-activity>, <uiset-agent-snapshot> | The human side of the agent bridge: status, steps, approvals, activity with undo, and what the agent sees |
<uiset-terminal> | A terminal: ANSI output, a prompt with history and Tab completion, Ctrl+C and Ctrl+L |
<uiset-line-chart>, <uiset-bar-chart>, <uiset-scatter-chart>, <uiset-heatmap>, <uiset-segmented-bar> | The charts, drawn from the same geometry as the React ones |
<uiset-sparkline>, <uiset-stat> | A trend in a line of text, and a figure with its change in words and an optional trend |
<uiset-data-grid> | The data grid, from rows, columns and getRowId |
<uiset-chat>, <uiset-chat-composer>, <uiset-chat-threads>, <uiset-chat-model-picker>, <uiset-chat-usage> | A conversation from messages with parts, its message box, the list of chats, a model picker and a context meter |
<uiset-rich-text>, <uiset-rich-text-content> | The rich text editor (toolbar, "/" and "@", Markdown in and out), and saved text shown read-only |
<uiset-app-shell>, <uiset-page-header>, <uiset-empty-state> | An app's frame (a sidebar that collapses to icons, a drawer on phones), a page's heading with actions, and what to do when there's nothing yet |
<uiset-search-field>, <uiset-copy-button>, <uiset-stepper> | A search box with Clear and a "/" key, a copy button that says Copied, and the steps of a flow |
Studio
The patch bay is a UISet element; faders, level meters and step sequencers are UI SDK's slider, meter and toggle group in UISet's classes, as the React ones are.
<uiset-patch-bay aria-label="Patch bay">
<uiset-patch-bay-jack value="lfo:out"><button aria-label="LFO out"></button></uiset-patch-bay-jack>
<uiset-patch-bay-jack value="cutoff:in"><button aria-label="Cutoff in"></button></uiset-patch-bay-jack>
</uiset-patch-bay>
<div class="fader-field">
<ui-slider orientation="vertical" default-value="70">
<ui-slider-value></ui-slider-value>
<ui-slider-control><ui-slider-track>
<ui-slider-indicator></ui-slider-indicator><ui-slider-thumb aria-label="Level"></ui-slider-thumb>
</ui-slider-track></ui-slider-control>
</ui-slider>
<span class="fader-label" aria-hidden="true">Level</span>
</div>
<ui-meter class="level-meter" value="62" aria-label="Signal">
<ui-meter-track><ui-meter-indicator></ui-meter-indicator></ui-meter-track>
</ui-meter>
<ui-toggle-group multiple class="step-sequencer" aria-label="Steps" default-value="1,5" style="--steps: 8">
<ui-toggle value="1"><button aria-label="Step 1" data-downbeat></button></ui-toggle>
…
</ui-toggle-group>
- A port whose value ends in
:outpatches into one ending in:in; an input takes one cable, and patching a connected pair again pulls the cable out. bay.valuereads and sets the cables ([{ from, to }]), andvaluechangefires when a person or agent changes them.bay.api.plug(from, to),unplugandclearwork by port or by name.- Ports are named by their buttons'
aria-labels, for agents and for each jack's "Patched to …" description, unless you setbay.labelsyourself. - Mark the current step with
data-currentand the downbeats withdata-downbeaton the buttons.
Mobile
<uiset-pull-refresh>
<uiset-swipe-list aria-label="Messages" openable></uiset-swipe-list>
</uiset-pull-refresh>
list.items = messages.map((m) => ({
id: m.id,
label: `${m.from}: ${m.subject}`,
content: `${m.from} · ${m.subject}`, // or a Node
start: [{ id: "read", label: "Read", icon: "mail" }],
end: [
{ id: "archive", label: "Archive", icon: "archive", tone: "neutral" },
{ id: "delete", label: "Delete", icon: "trash", tone: "danger" },
],
}))
list.addEventListener("action", (e) => run(e.detail.value, e.detail.action))
list.addEventListener("open", (e) => open(e.detail.value)) // with `openable`
refresh.addEventListener("refresh", (e) => e.detail.waitUntil(fetchMail()))
- Set
itemsagain whenever your data changes; rows are kept byid, so the focused row, an open row and their elements stay. After an action, focus goes back to its row, or to the row now in its place. - Everything the React SwipeList does is here: a long swipe runs a side's first action, each
row has an Actions menu for screen readers,
messagestranslates the words, and agentsrun(row, action)andopenRow(row)by name (adangeraction needs the person). <uiset-pull-refresh>scrolls its children and slides them down as it's pulled. A rejectedwaitUntilPromise is announced and firesrefresherror;refresh()runs one from code, and a Refresh button shows on keyboard focus.thresholdandmessagestune it.<uiset-wheel>binds UI SDK'sbindScrollPickerto the listbox inside it, and keeps focus on the value when it changes from elsewhere (an agent'ssetValue, say):
<div class="wheel-group" role="group" aria-label="Alarm time">
<uiset-wheel>
<ui-listbox class="wheel-list" keep-in-view="false" aria-label="Alarm time, hour" default-value="7">
<ui-listbox-item class="wheel-option" value="1">1</ui-listbox-item>
…
</ui-listbox>
</uiset-wheel>
</div>
- Sheets, action sheets, the app bar and the tab bar are UI SDK's drawer and buttons in
UISet's classes:
mobile-sheet(adddata-snapswith several snap points),mobile-sheet-handle,mobile-actionwithdata-destructive,mobile-app-bar,mobile-tab-barandmobile-tabwitharia-current="page". An action sheet's choice comes back fromawait drawer.api.open()asreturnValue, from<ui-drawer-close value="…">.web/mobile.htmlhas all of it working. iconElement(name)(exported fromuiset.js) draws UISet's icons without React.
Board
<uiset-board aria-label="Sprint board" today="2026-11-15" addable openable></uiset-board>
board.columns = [{ id: "todo", title: "To do" }, { id: "doing", title: "Doing", limit: 3 }, { id: "done", title: "Done" }]
board.cards = { c1: { id: "c1", title: "Write the spec", assignee: "Grace Hopper", labels: ["Docs"] } }
board.lanes = { todo: ["c1"], doing: [], done: [] }
board.addEventListener("laneschange", (e) => save(e.detail.value)) // also e.detail.key, from, to, index
board.addEventListener("open", (e) => showCard(e.detail.value)) // Enter or a click
board.addEventListener("add", (e) => create(e.detail.value, e.detail.title))
lanesis live: read it any time (you get a copy), set it to change the board (an undo, a server update). Setcardsagain when a card changes: a card is kept by id and filled again in place, so it keeps focus, and an open "Add a card" form stays. Renamed columns, a newrenderCardand a changedtodayattribute redraw too.addableshows "Add a card" andopenablemakes Enter and clicks open cards. Without them the board does neither, and agents are told so.- Arrows move between cards and columns; Space picks a card up, arrows carry it (the board
redraws as it goes), Space drops it and Escape puts it back. Column
limits hold for people, agents andboard.api.moveCard. addfires when someone submits "Add a card": add the card tocardsandlanesyourself. A column that fills hands focus to its last card. Counts include every id in a column, as the limit check does.hiddenCards(ids) hides cards in place for a search or filter: focus skips them (a focused card that gets hidden hands focus to its neighbor) and moves wait until it clears. Ids that aren't on the board don't hold moves back. (It isn'thidden, which hides the element itself.)- The board's help is added to your own
aria-describedby, not put in its place. describeCard(card)sets what screen readers hear after a title,renderCard(card)returns your own card face (a Node), andmessagesholds the words (help,empty,full,filtered).board.apihasmoveCard(card, column, position?),addCard,openCard,focusCard,cardElement,neighbor(where focus goes when a card leaves) andhasRoom, by id or by the names people see. Agents use the same names.- Card details are your page's: open a
ui-dialogonopen. On web components itsfinal-focusis a selector, read as it opens: point it atuiset-board-card[value="…"].web/board.htmlhas a dialog that moves cards (a full column says so) and deletes them, returning focus to the card or its neighbor; each move and delete has its own Undo.
Scheduler
<uiset-schedule aria-label="Team week" start-hour="8" end-hour="18" today="2026-11-16" time-zone="UTC" openable></uiset-schedule>
<uiset-schedule-month month="2026-11" today="2026-11-16" label="November 2026"></uiset-schedule-month>
schedule.tracks = [{ id: "2026-11-16", label: "Monday, Nov 16", short: "16 Mon" }, …] // days, or rooms
schedule.events = [{ id: "e1", track: "2026-11-16", start: 9 * 60, end: 10 * 60, title: "Standup", color: 1 }]
schedule.addEventListener("eventschange", (e) => save(e.detail.value)) // a draw, move, resize or delete
schedule.addEventListener("open", (e) => showEvent(e.detail.value)) // Enter or a click
schedule.create = (span) => ({ ...span, title: "New event" }) // what a drag or click draws
month.events = schedule.events
month.addEventListener("daychoose", (e) => showDay(e.detail.value))
- Times are minutes past midnight; a track is a day (an ISO date) or anything else, like a
room.
axis="x"lays tracks out as rows (a timeline).start-hour,end-hour,step(minutes, 15 by default),size(the default length) andoverlap="false"(rooms) are attributes, and changing one redraws. - Each event is a button: drag it to move, drag its end edge to resize, drag an empty
stretch to draw. Arrows move it by a step or a track, Shift with arrows changes its end,
Delete removes it and hands focus to its neighbor, Enter opens it (with
openable; without it the schedule doesn't open events, and agents are told). Events keep their elements as they move, so a drag holds its pointer and focus stays. eventsis live: read it, or set it (an undo, a server update).locked(event)keeps events from changing,timeLabel(minutes)sets how times read (12-hour English by default),renderEvent(event)returns your own face, andnowwithtodaydraws the current-time line. Youraria-describedbystays, with the schedule's help after it.schedule.apihasaddEvent,moveEvent,resizeEvent,removeEvent,openEvent,problem(event)(why an event wouldn't fit: hours, steps, overlaps),eventElementandneighbor. Agents add, move, resize and open events by the names people see.<uiset-schedule-month>is UI SDK's calendar with each day's first events as chips (limit, 3 by default) and the events in each day's name. Choosing a day firesdaychoose; the month buttons firemonthchange.- The toolbar and the event dialog are your page's:
web/schedule.htmlhas Day, Week, Month and Rooms, a dialog that checks times withapi.problem, and focus that returns to the event or a deleted one's neighbor.
Canvas
<uiset-canvas-toolbar for="workflow"></uiset-canvas-toolbar>
<uiset-canvas id="workflow" label="Workflow" fit-view openable history></uiset-canvas>
<uiset-canvas-inspector for="workflow" renamable></uiset-canvas-inspector>
canvas.kinds = [{ id: "prompt", label: "Prompt" }, { id: "model", label: "Model" }]
canvas.nodes = [{ id: "a", kind: "prompt", title: "Answer template", x: 40, y: 40, outputs: [{ id: "out", label: "Prompt" }] }, …]
canvas.edges = [{ id: "a->b", from: "a:out:out", to: "b:in:prompt" }]
canvas.createNode = (kind, at, title) => ({ id: crypto.randomUUID(), kind, title: title ?? "New node", ...at })
canvas.addEventListener("nodeschange", (e) => save(e.detail.value)) // e.detail.type: move, add, remove, edit
canvas.addEventListener("edgeschange", (e) => saveWires(e.detail.value))
inspector.renderFields = (node) => myFields(node) // your fields, under Rename
- The canvas owns its
nodesandedgesonce you set them, like a form field: a drag, a removal, an add or a wire changes them and firesnodeschangeoredgeschangefirst, andpreventDefault()refuses the change. Setting them yourself replaces them. - Drag empty space to pan, scroll or pinch to zoom, Shift-drag to select a box. Tab
reaches the nodes once; arrows go to the nearest node that way, Alt with arrows moves
it, Space selects, C starts a wire (arrows choose an input, Enter connects), Delete
removes (with its wires), Enter opens (with
openable). Plus and minus zoom; 0 resets. historykeeps an undo stack: Control+Z and Control+Shift+Z on the canvas,canvas.undo(),redo(),canUndoandcanRedo, andhistorychange. A removal takes its wires in the same step, andcanvas.updateNode(id, patch)(the inspector's Rename) is a step too.fit-viewframes every node once the canvas has a size.controls="false"andminimap="false"leave out the zoom buttons and the map.gridsets the snap and the Alt+arrow step.kinds,canConnect(from, to),locked(node)andrenderNode(node)are properties, as in React.canvas.apihasaddNode,moveNode,connect,disconnect,select,openNode,fitView, the zoom steps,focusNode,nodeElement,selectedNodesandremoveSelected. Agents add, wire, move and select nodes by the names people see.<uiset-canvas-toolbar for="…">is Add node (the canvas's kinds), Remove, Undo and Redo (withhistory) and Fit view on UI SDK's toolbar.<uiset-canvas-inspector for="…">shows the selected node's title, a Rename form (renamable), your fields, and its wires, each with Disconnect, which hands focus to the next wire or the node.
IDE
<uiset-command-palette id="palette" lines></uiset-command-palette>
<uiset-file-tree id="files" label="Files" default-expanded="src" creatable renamable movable deletable></uiset-file-tree>
<uiset-editor-tabs id="tabs" empty="No file open."></uiset-editor-tabs>
<uiset-problems id="problems"></uiset-problems>
<uiset-status-bar><span class="ide-status-item">main</span></uiset-status-bar>
files.paths = Object.keys(project) // a path ending in "/" is an empty folder
files.status = { "src/index.ts": "modified" } // version control marks
files.addEventListener("open", (e) => openFile(e.detail.value))
files.addEventListener("rename", (e) => rename(e.detail.from, e.detail.value)) // or e.detail.refuse("why")
tabs.files = [{ path: "src/index.ts", dirty: true }]
tabs.renderPanel = (path) => Object.assign(document.createElement("uiset-code-editor"), { value: project[path] })
tabs.addEventListener("close", (e) => closeFile(e.detail.value))
palette.files = Object.keys(project)
palette.addEventListener("openfile", (e) => openFile(e.detail.value))
- Each part is its own element and keeps no project of its own: you hold the files, and
each part shows what you give it and fires what people do.
web/ide.htmljoins them into the React IDE template's editor, from the same sample project and linter (templates/ide-sample.ts). <uiset-file-tree>is UI SDK's tree: folders first, marks fromstatus(in words after each name), New file and New folder (creatable), Rename with F2 (renamable), Move (movable), Delete with the Delete key after a confirm (deletable).create,renameandmovecarryrefuse(message), which keeps the dialog open with your reason. A new or renamed item takes focus; a deleted one hands it to its neighbor. An agent's choice of a file opens it.<uiset-editor-tabs>is UI SDK's tabs.renderPanel(path)fills a file's panel once and it stays while the tab is open, so an editor keeps its caret, scroll and undo. Delete on a tab, a middle click or its × firesclose; once the file leavesfiles, focus goes to the tab that took over, or the empty pane.<uiset-code-editor label path tab-size readonly>is UI SDK's textarea (agents read and write it) with line numbers and colors behind the text. Tab and Shift+Tab indent, Escape then Tab leaves, Enter keeps the indent, F8 and Shift+F8 walkproblems, and its own undo keeps a run of typing as one step and a change from outside (settingvalue) as another.valuechangeandcursorchangereport typing and the caret;goTo(line, col)moves it. Text inside the element is its first value.<uiset-command-palette lines>opens files with ⌘P (Ctrl+P), commands after>with ⌘⇧P, and lines after:, on UI SDK's command in a dialog. Escape goes back where you were.<uiset-problems>listsproblemsworst first and fireschoose;<uiset-status-bar>is the labeled strip for your items.
Agent Console
<uiset-agent-console title="Agent" status="idle">
<uiset-agent-step status="done">Rename the project</uiset-agent-step>
<uiset-agent-activity></uiset-agent-activity>
<uiset-agent-snapshot within="#settings"></uiset-agent-snapshot>
<uiset-agent-approvals></uiset-agent-approvals>
</uiset-agent-console>
import { createAgent } from "@ui-sdk/web/agent"
import { createHistory } from "@ui-sdk/web/history"
import { createApprovals } from "@/components/ui/agent/approvals"
const approvals = createApprovals()
const agent = createAgent({ allow: approvals.allow })
approvalsEl.approvals = approvals
activityEl.history = createHistory(agent, { within: () => settings })
snapshotEl.agent = agent
- The console is a labeled region with the agent's status (
idle,working,waiting) in words, and it's hidden from agents, so they never act on their own console. <uiset-agent-step status>is one step of a plan: running, done, failed, or "You said no".<uiset-agent-approvals>shows each pending approval as a card with Allow and Don't allow. Steps on controls markeddata-agent-confirmalways ask. The cards go to the top layer, above a modal the person has open, and stay usable while it is.<uiset-agent-activity source="agent">lists what the agent changed, newest first: Show outlines the control, and Undo agent changes puts back what it changed and leaves what people changed.<uiset-agent-snapshot within>is a table of what the agent can see: each control, its kind, its state and its actions.web/agent.htmlruns a stand-in model (the gallery's planner) against project settings, so the whole loop runs without an API key.
Terminal
<uiset-terminal label="Shell" prompt="~/app $" welcome="Type help for the commands."></uiset-terminal>
shell.commands = {
echo: { description: "Prints its words", usage: "<words…>", run: (args) => args.join(" ") },
build: {
description: "Builds, with a progress bar",
async *run(_args, io) {
for (let p = 0; p <= 100; p += 20) yield `\r${"█".repeat(p / 10).padEnd(10, "░")} ${p}%`
yield "\n\x1b[32m✓\x1b[0m Built\n"
},
},
}
shell.history = ["ls"] // Up brings these back first
shell.handle = (line, io) => socket.send(line) // lines no command matched
shell.api.print("from the server\n") // output from outside, like a socket
- It runs on the React Terminal's own session (
terminal/session.ts) and screen (terminal/ansi.ts): a command prints, returns text, streams an async iterable (a progress bar redraws its line with\r), or throws (shown in the danger tone).help,clearandhistorycome built in. - Up and Down bring back earlier commands, Tab completes a command or (with a command's
complete) its argument, and a second Tab lists several matches. Ctrl+C stops a running command (the prompt comes back at once), and with text selected it copies. Ctrl+L clears. - Output keeps ANSI colors, readable in every skin and theme, and links (OSC 8 and plain
http addresses). The log reads out new lines only, and old lines go only while you're
following the end, past
scrollbacklines. "New output below" appears when you've scrolled up. shell.apihasrun,clear,printanderror. Agents run commands and read the last lines; while a command runs, they're told to wait.
Charts
<uiset-line-chart id="revenue" label="Revenue" description="By month"></uiset-line-chart>
Object.assign(document.getElementById("revenue"), {
data: months,
x: (d) => d.month,
series: [
{ key: "revenue", label: "Revenue", value: (d) => d.revenue },
{ key: "costs", label: "Costs", value: (d) => d.costs },
],
format: (n) => `$${n / 1000}k`,
})
- Each chart's geometry lives in
registry/uiset/ui/chart/plots/as plain functions that return nodes; the React charts and these elements draw the same nodes, so they can't drift. - The title is
label(atitleattribute would give the whole chart the browser's tooltip).descriptionandheightare attributes too. The options React takes as props are properties here (data,x,series,format, …), or attributes where they're words:layouton line charts;orientation,layoutandsorton bar charts;x-labelandy-labelon scatter charts;measureon heatmaps;varianton segmented bars;localeon all. Every attribute has a property that reflects it, sochart.orientation = "horizontal"works too. - A scatter chart's point names come from
pointLabel(React'slabel), sincelabelis the title on every chart. - Setting properties redraws once, on the next microtask, however many you set together. Properties set before the element is defined are picked up when it is.
- Until its required options arrive, a chart shows its title and nothing else, with
aria-busyon its figure. - Every chart is a listbox of its data positions: arrows move (by row in a heatmap), the
tooltip and crosshair follow focus or the pointer (the nearest point, on a scatter chart),
and Enter pins one.
selectedis live.selectfires with{ value, source, reason }:sourceiskeyboard,pointeroragent, andpreventDefault()refuses the pin. When new data drops the pinned position,selectfires once withvalue: null,source: "data",reason: "removed". <uiset-sparkline label values="4,6,,8" variant="line|area|bar" series="1">needs no script:valuesis comma-separated (an empty place is a gap), and its name says where the values went ("from 4 to 8, low 4, high 8").<uiset-stat label value change direction good period trend description>says its change in words ("Up 12%") as well as color;good="false"marks a rise as bad. Both takevalues/trendandformatas properties too.- The legend isolates a series; Chart and Table switch to the table twin. Agents
selectby reading ("Mar"),isolateandshowAllseries, andshowTable, as in React. With the table showing, the switch is how an agent gets back to the chart.
Data Grid
<uiset-data-grid label="Customers" selectable editable toolbar filename="customers" height="26rem"></uiset-data-grid>
grid.columns = [
{ id: "name", header: "Name", value: (c) => c.name, pinned: true, editable: true, hideable: false },
{ id: "plan", header: "Plan", value: (c) => c.plan, options: ["Free", "Pro", "Team"], editable: true },
{ id: "seats", header: "Seats", type: "number", value: (c) => c.seats, editable: true },
{ id: "renews", header: "Renews", type: "date", value: (c) => c.renews, text: (c) => day.format(c.renews) },
{ id: "active", header: "Active", type: "boolean", value: (c) => c.active, editable: true },
]
grid.getRowId = (c) => c.id
grid.rows = customers
grid.addEventListener("celledit", (e) => {
const { rowId, column, value } = e.detail // value is parsed: a number, a Date, a listed option
if (!allowed(value)) return e.preventDefault() // refused: the editor stays open
save(rowId, column, value)
})
grid.addEventListener("rowaction", (e) => open(e.detail.row))
- Columns are the React grid's (
type,options,width,minWidth,align,sortable,filterable,groupable,editable,pinned,hidden,hideable), andrender(row)returns a Node or text. Both sharedata-grid/columns.tsanddata-grid/view.ts. - Attributes:
label,locale,selectable,editable(withcelleditlistening),height,empty,persist(a localStorage key for the layout),density,toolbarandfilename(for Export CSV). - State is live and reported:
selected(selectedchange),sort(sortchange),query(querychange),filters(filterschange),groupBy(groupbychange),columnState(columnstatechange) anddensity(densitychange). Setting one fires no event; a person's or agent's change does, andpreventDefault()cancels it: a canceled change isn't announced, doesn't move focus, and fails the agent step that asked for it. rows,columns,getRowIdand the state above can be set before the element is defined; they're picked up when it is.- Everything the keyboard does in React works here: one tab stop, arrows, Page Up and Down, Control+Home and End, Shift to select a range, copy and paste, Enter or typing to edit, Space to check, Shift+F10 for the column menu, Alt+arrows to resize, Alt+Shift+arrows to move. Only the rows near the view are drawn, in order. When rows or columns go (a search, a hidden column), the active cell moves to the nearest one left, so the grid keeps its tab stop. An edit left open with text that doesn't fit gives way when another cell starts one.
- Header cells, their menus and the toolbar's tools are kept as the grid redraws: an open column menu stays open (Wider and Narrower keep it open), and closing it returns focus to its header. Double-clicking a resize edge resets the width. A redraw only touches the cells whose row, column or text changed, and a resize drag only changes widths.
toolbaradds search (or the selection, with Clear selection), column filters, grouping, the View menu (columns and density) and Export CSV. Each tool shows the grid's state however it changed, and followslabeland header changes.grid.exportCsv(name)andgrid.focusGrid()work from code.- Agents
sortBy,filter,filterColumn,selectRows,clearSelection,editCell,groupByandshowColumnby the names people see, with the same checks.
Chat
<uiset-chat-threads label="Chats" value="t1" newable renamable deletable></uiset-chat-threads>
<uiset-chat id="chat" label="Conversation" editable></uiset-chat>
<uiset-chat-composer id="composer" label="Message" attachments="image/*,.pdf"></uiset-chat-composer>
// With AI SDK's useChat (Vue, Svelte or plain JavaScript), its state goes straight in:
chat.messages = messages // [{ id, role, parts: [{ type: "text", text }, …] }]
chat.status = status // "ready" | "submitted" | "streaming" | "error"
composer.toggleAttribute("running", status === "submitted" || status === "streaming")
composer.addEventListener("send", (e) => sendMessage({ text: e.detail.text, files: e.detail.files }))
composer.addEventListener("stop", () => stop())
chat.addEventListener("retry", () => regenerate())
- Parts drawn:
text(markdown, with GitHub tables, task lists and code blocks highlighted by shiki),reasoning(open while it streams, then "Thought for 2s"), tool calls (tool-*anddynamic-tool, with their state, input, output or error),source-url(gathered into "Used 3 sources"),file(images shown, others linked) and UISet's owntasks({ type: "tasks", title, tasks: [{ id, title, status }] }). - Raw HTML in markdown is dropped, never rendered: model output is untrusted. While a text part streams, half-written markdown is completed (remend) so it doesn't flicker.
- The markdown parser is remark, as React's response uses, and loads only when a chat first draws an answer.
- Under each answer: Copy, Retry (on the last answer, firing
retry) and Good or Bad (feedbackchangewith{ value, id };chat.feedbackis live). Witheditable, a user's message has Edit (edit). A tool call inapproval-requestedshows Allow and Deny and firesapprovewith{ value, id, part }; Allow carriesdata-agent-confirm. suggestionsshow in the empty state, and a click firessuggestion.- The log follows new text unless you've scrolled up, then offers Jump to latest.
<uiset-chat-composer>sends on Enter (Shift+Enter for a new line), shows Stop whilerunning, brings back what you sent with Up and Down, takes files withattachments(its value is the accept list), and suggestscommandsafter "/" andmentionsafter "@". Its event issend, notsubmit, which its own form's native event would share.<uiset-chat-threads>takesthreads({ id, title, updatedAt? }) andvalue. Choosing a chat setsvalueand firesvaluechange(cancelable; notselect, which a text field's native event shares). It also firesnew,renamewith{ value, title }anddelete(after the person confirms in an alert dialog).- The composer's children go in its toolbar, beside Send.
<uiset-chat-model-picker label="Model" value="balanced">takesmodels({ value, label, description? }) and firesvaluechange;<uiset-chat-usage used="1200" limit="200000">is a meter that reads "1.2K of 200K tokens" and turns amber past 70% and red past 90%.
<uiset-chat-composer label="Message" attachments>
<uiset-chat-model-picker id="model" value="balanced"></uiset-chat-model-picker>
<uiset-chat-usage used="0" limit="200000"></uiset-chat-usage>
</uiset-chat-composer>
Rich text
<uiset-rich-text id="notes" label="Release notes" placeholder="Write the notes…"
value="## Release 2.4" name="notes" format="markdown" max-length="2000"></uiset-rich-text>
<uiset-rich-text-content id="preview"></uiset-rich-text-content>
notes.mentions = [{ value: "ada", label: "Ada Lovelace" }]
notes.uploadImage = async (file) => ({ src: await upload(file), alt: file.name })
notes.addEventListener("valuechange", (e) => {
save(e.detail.value) // Markdown
preview.doc = e.detail.doc // the document, drawn read-only
})
- The same editor as React's
RichTextEditor: the toolbar is<ui-toolbar>with<ui-toggle>s, a<ui-select>for the block type and a<ui-popover>for links; "/" and "@" are<ui-trigger-menu>. The keys, Markdown shortcuts, paste rules and HTML output are the ones in Rich text. value(attribute or property) is Markdown;docis the document.valuechangefires with{ value, doc }for edits, not caret moves;submitfires on Mod+Enter outside a to-do.commandsreplaces what "/" offers ([]turns it off), with the same shape as React's.toolbar="false"leaves the toolbar out;readonlyhides it and refuses edits;disableddims it too.- With
name, a hidden field carries the HTML, or Markdown withformat="markdown". - Agents see
<uiset-rich-text-area>, the editing surface, named bylabel, withsetValue,insertandcleartaking Markdown. <uiset-rich-text-content value="…">(or itsdocproperty) draws saved text with the editor's styles, from the document, never from an HTML string.
App Shell
<uiset-app-shell label="Main navigation">
<a data-part="brand" href="/">Acme</a>
<nav data-part="sidebar" class="sidebar-nav" aria-label="Main">
<a class="sidebar-link" href="/" aria-current="page"><span class="sidebar-icon"></span><span class="sidebar-label">Home</span></a>
</nav>
<div data-part="header">…search, account…</div>
<uiset-page-header title="Customers" description="Everyone who has an account.">
<ui-button><button>Add customer</button></ui-button>
</uiset-page-header>
…the page…
</uiset-app-shell>
<uiset-app-shell>lays out its children bydata-part:brandat the top of the sidebar,sidebarunder it,headerin the header, and the rest in a<main>(or a<div>withcontent="div", when the page has its own).- Mod+B (or the toggle) collapses the sidebar to icons and back, remembered in the same
storage as the React AppShell (
uiset-sidebar), and each link then gets a tooltip. Belownarrow-below(48rem by default) the sidebar moves into a drawer opened from the header. <uiset-page-header title description level>is the page'sh1(orh2) with your actions as children.<uiset-empty-state title description icon level>says what to do when there's nothing yet, with its actions as children. On both,titlenames the page or state; it isn't a tooltip.
Page templates
Every React page template is a web page too, in web/templates/, built only from UI SDK's
elements and UISet's. Each one's sample data is a framework-free module the React template
imports as well (registry/uiset/ui/templates/*-sample.ts), so the two show the same thing.
| Page | What it shows |
|---|---|
auth.html | Sign in, sign up, forgot and reset password, verify a code: browser checks, server messages, a strength meter |
pricing.html | Plans with a monthly or yearly switch, a comparison table and questions |
dashboard.html | A date range that rescopes the figures, charts and recent orders, in the App Shell |
admin.html | Records with search, filters and export; create and edit in a drawer; confirmed bulk delete |
settings.html | Profile, security, notifications, billing with usage and invoices, team, and a danger zone |
inbox.html | Folders, a message list you arrow through, the message with star, archive, delete and Undo, and compose |
chat-app.html | Chats, the conversation and a composer with a model picker and usage, streaming from a stand-in model |
project-board.html | Sprint progress and the issues as a board or a list, with search, a person filter, card details, a new-issue form and Undo |
calendar-app.html | A day, a week (uiset-schedule) or a month (uiset-schedule-month), a small month that jumps to a day, calendars to show or hide, and what's next |
../ide.html | The IDE in resizable panes |
The chat app streams from sampleAssistant (templates/chat-app-sample.ts), an async
generator of events (text, reasoning, tool calls, sources). Swap in your model there; the
page turns its events into the chat's message parts, the same shape AI SDK's useChat
holds, so useChat's messages and status can go into <uiset-chat> directly instead.
Vue
Tell Vue the tags are custom elements, then use them in templates:
// vite.config.ts
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("ui-") || tag.startsWith("uiset-") } } })
<uiset-root :skin="skin"><uiset-knob default-value="40" @valuechange="onChange">…</uiset-knob></uiset-root>
Or use UI SDK's Vue adapter (@ui-sdk/vue) for its components and load only uiset.css:
the same data-slot attributes mean the same styles.
Svelte and Solid
Both render custom elements as they are. Listen with onvaluechange in Svelte 5, or
on:valuechange in Solid. As with Vue, UI SDK's own adapters work with uiset.css too.
React and the web
Every kit is an element set, and every page template is a web page: Studio, Mobile, Board,
Scheduler, Canvas, the IDE, the Agent Console, the Terminal, Charts with the sparkline and
stat, the Data Grid, Chat and the App Shell, and the auth, pricing, dashboard, admin,
settings, inbox, chat app and IDE pages. The rest of the registry wraps UI SDK's own
components, which are already elements here (ui-*, dressed by uiset.css); UISet's
pieces without behavior (badge, alert, card, item and description lists, breadcrumbs,
skeleton, spinner, kbd) are classes in the same stylesheet.
The three small parts with behavior of their own are elements too:
<uiset-search-field label="Search mail" placeholder="Search mail…" shortcut="/"></uiset-search-field>
<uiset-copy-button value="npm i @uiset/web" label="Copy" copied-label="Copied"></uiset-copy-button>
<uiset-stepper label="Setup progress" value="details"></uiset-stepper>
<uiset-search-field>firesvaluechangewith{ value };shortcut("/" by default,shortcut=""for none) focuses it from anywhere, through UI SDK's shared shortcuts, and shows as a key hint until something is typed. Clear empties it and keeps focus.<uiset-copy-button>copiesvalue, announces "Copied" and shows it for a moment.<uiset-stepper>takessteps({ value, label, description? }) andvalue; choosing a step setsvalueand firesvaluechange. Withlinear(on unlesslinear="false"), later steps wait until the current one is reached.
Tests
tests/e2e/web/ runs against plain HTML with no React on the page: axe in all three skins
and both themes, the root's attributes, skins restyling UI SDK's elements, the knob and
swipe rows by keyboard, the patch bay by keyboard, by script and by an agent, the Studio
instruments, a UI SDK dialog's focus, and the built files themselves. mobile.spec.ts,
board.spec.ts, scheduler.spec.ts, canvas.spec.ts, ide.spec.ts,
agent-console.spec.ts, terminal.spec.ts, charts.spec.ts, data-grid.spec.ts and
chat.spec.ts cover those kits' pages, and templates.spec.ts the page templates (web/mobile.html, web/board.html,
web/schedule.html, web/canvas.html, web/ide.html, web/agent.html,
web/terminal.html, web/charts.html, web/data-grid.html, web/chat.html) the same way: axe in all six skin and theme pairs,
keyboard, pointer, events, script and agents.