@luntta/aihio
v2.5.0
Published
AI-first design system built on native web components. Zero dependencies.
Maintainers
Readme
Aihio
AI-first design system built on native web components. Zero runtime dependencies.
Aihio is designed for AI agents to generate markup predictably — every component has a machine-readable schema, predictable attributes, flat composition, an intent vocabulary that maps prompt meaning onto components, and semantic tokens that name what each style value is for. Visually it follows a clean, minimal aesthetic with an extensive Oklch-based token system and light/dark mode support.
Install
npm install @luntta/aihioUsage
<link rel="stylesheet" href="node_modules/@luntta/aihio/dist/aihio.css">
<script type="module" src="node_modules/@luntta/aihio/dist/aihio.js"></script>
<aihio-button variant="outline">Click me</aihio-button>Or import as a module:
import '@luntta/aihio/css';
import '@luntta/aihio';That entrypoint auto-registers all custom elements.
During development, import the dev bundle instead to get schema-backed console warnings for invalid attributes and accessibility-contract violations:
import '@luntta/aihio/dev'; // same components, warnings installedThe warnings are a separate module that the production bundle never imports, so
dist/aihio.js carries none of that code. If you only want the classes without side effects:
import { AihioButton, AihioDialog } from '@luntta/aihio/components';Each component also has a focused, tree-shakeable entrypoint. These entrypoints export classes but do not register them for you:
import '@luntta/aihio/css';
import { AihioButton } from '@luntta/aihio/button';
if (!customElements.get(AihioButton.tag)) {
customElements.define(AihioButton.tag, AihioButton);
}The same form is available for all 21 top-level components. Import
aihio/runtime when schema inspection is needed without importing component
implementations.
The root bundle also exposes Aihio.describe() for runtime introspection:
import { Aihio } from '@luntta/aihio';
const buttonSchema = Aihio.describe('aihio-button');
// { $component: 'aihio-button', version: '1.5.0', ... }Schema-derived TypeScript declarations ship in dist/aihio.d.ts, including intent and variant unions plus JSX element typings:
import type { AihioButtonVariant, AihioIntent } from '@luntta/aihio';
const variant: AihioButtonVariant = 'outline';
const intent: AihioIntent = 'primary-action';
export function Toolbar() {
return <aihio-button data-aihio-intent={intent} variant={variant}>Save</aihio-button>;
}The package also exports a canonical prompt fragment for AI systems:
import prompt from '@luntta/aihio/prompt';Schema-backed markup linting is available both as a library and a CLI:
import { lintMarkup } from '@luntta/aihio/lint';
const result = lintMarkup('<aihio-button variant="primary"></aihio-button>');npx aihio lint ./example.html
cat ./example.html | npx aihio lint -Alongside the composition and a11yContract rules, the linter checks
data-aihio-intent annotations against the schema: unknown-intent for a name
outside the vocabulary, and intent-mismatch for a real intent on a component
that does not declare it. Annotating is optional, but an annotation that is
present has to be true.
$ npx aihio lint badge.html
<aihio-badge> is annotated data-aihio-intent="primary-action",
but its schema declares status, labeling, metadata.unknown-attribute reports anything on an Aihio element that its schema does
not declare — href or icon on a button, spacing on a stack — since the
browser never will: nothing reads an invented attribute. Global HTML
attributes, aria-*, data-*, event handlers, and template bindings
(@click, :value, v-if) are left alone.
Where the fix is unambiguous, an issue carries a suggestion: the replacement
tag, attribute, or value, written as markup. It covers vocabulary carried over
from other design systems as well as typos, so an agent can apply it without
parsing the message:
lintMarkup('<aihio-button variant="primary">Save</aihio-button>').issues[0].suggestion;
// 'variant="default"'
lintMarkup('<aihio-modal></aihio-modal>').issues[0].suggestion;
// '<aihio-dialog>'A few rules catch markup that is valid but does not do what it says:
boolean-attribute-value (pressed="false" turns a toggle on: a boolean
attribute is on whenever it is present), cluster-needs-grow (a justified
cluster in a card footer, where it has no room to justify),
table-sortable-header (<th sortable>, which nothing reads), and
hand-rolled-layout (an inline display: grid or flex that one of the
layout primitives replaces). An a11y-contract issue names the obligation it
enforces in contract, such as button-accessible-name.
For MCP clients that launch local stdio servers, the package also ships an MCP
server, started with aihio mcp. Its tools take an agent from a request to
checked markup:
| Tool | Returns |
| --- | --- |
| find | Components and patterns ranked against what the UI has to do ("confirm before deleting a project") or an intent name |
| list_components, list_patterns | The whole inventory, with each entry's purpose and intents |
| get_pattern | A canonical pattern's lint-clean markup and variations |
| describe | One component's full schema |
| lint | Structured issues, with a suggestion where the fix is unambiguous |
The prompt fragment is served as the MCP prompt aihio-authoring.
{
"mcpServers": {
"aihio": {
"command": "npx",
"args": ["-y", "@luntta/aihio", "mcp"]
}
}
}In Claude Code: claude mcp add aihio -- npx -y @luntta/aihio mcp.
Components
| Component | Description |
|-----------|-------------|
| aihio-stack | Vertical layout primitive applying the system spacing scale |
| aihio-cluster | Horizontal layout primitive for button rows, badge lists, and toolbars |
| aihio-grid | Grid of equal columns, such as cards, that drops columns as it narrows |
| aihio-field | Form field wrapper that lays out label, control, description, and error, and wires the ARIA between them |
| aihio-button | Button with 6 variants (default, secondary, outline, ghost, link, destructive) and 4 sizes; wraps an <a href> to draw a link as a button |
| aihio-input | Text input with size variants and error state |
| aihio-switch | On/off setting (a real <input type="checkbox" role="switch">) that submits with its form |
| aihio-table | Data table around a native <table>: sortable columns, figures aligned by place value, and a scroll box the keyboard can reach |
| aihio-data-grid | Data grid for more rows than a page can hold: renders only the rows in view, with the WAI-ARIA grid keyboard |
| aihio-pagination | Previous, next, and the pages around the current one, as links (with href) or buttons |
| aihio-combobox | Filterable single-select field with keyboard navigation, async options, and optional free text |
| aihio-date-picker | Date field: typed the way the page's language writes dates, or picked from a calendar under it; submits YYYY-MM-DD |
| aihio-calendar | Month calendar on the page for choosing one date, such as a delivery day; submits YYYY-MM-DD |
| aihio-card | Content container with header, title, description, content, and footer sub-components |
| aihio-badge | Small status indicator with 6 variants (default, secondary, outline, success, warning, destructive) |
| aihio-alert | Callout with title/description slots and 4 variants; only destructive announces assertively |
| aihio-avatar | Image avatar with fallback initials |
| aihio-toggle | Pressed/unpressed button (a real <button aria-pressed>), such as Bold in a toolbar |
| aihio-tabs | Tabbed interface with keyboard navigation |
| aihio-dialog | Modal dialog with focus trap, ESC to close, backdrop |
| aihio-dropdown | Dropdown menu with keyboard navigation, typeahead, and link items (<aihio-dropdown-item><a href>) |
AI-First
Every component ships a JSON schema describing its API — attributes, slots, events, example markup, and now a seeded patterns library for multi-component page sections. The merged schema is available at dist/schema.json.
A component that enhances native markup rather than replacing it also declares
the attributes it reads on those elements, under nativeElements:
data-sortable on the <th> of an aihio-table, for instance. They are in
the minified schema, the prompt fragment, and the docs, and the linter holds
them to the same enum and boolean rules as the component's own attributes.
const schema = await fetch('node_modules/@luntta/aihio/dist/schema.json').then(r => r.json());
// { $schema: "aihio-design-system", version: "...", components: [...], patterns: [...] }AI agents can use this schema to understand and generate correct markup without reading documentation. For styling decisions, the semantic token vocabulary is available at dist/semantic-tokens.md, and with resolved values at dist/tokens.json.
The seeded patterns cover higher-level compositions such as auth forms, settings sections, destructive confirmations, tabbed settings, and toast-style alert stacks.
The component schemas also include author-facing a11yContract requirements and multiple counterExamples per component, so prompts, docs, and dev warnings can all point back to the same source-of-truth rules. Each counterexample carries its fix, and the rule that catches it. The build holds them to the linter: a counterexample has to be reported under the rule it names (one that names none, a judgment the linter cannot make, has to pass), and every fix, example, and pattern has to lint clean.
When you import aihio/dev, connected components also emit console warnings for schema-backed mistakes such as invalid enum attributes and machine-checkable a11yContract violations including unnamed icon buttons and toggles, unnamed dialogs, unlabeled inputs, icon-only dropdown triggers without labels, destructive alerts without announced content, and mismatched tab/panel values.
Forms
aihio-button wraps a real light-DOM <button> and aihio-input a real
light-DOM <input>, so a form built from Aihio components submits like any
other:
<form>
<aihio-field>
<label slot="label">Email</label>
<aihio-input type="email" name="email" autocomplete="email" required></aihio-input>
<span slot="description">Use your work address.</span>
</aihio-field>
<aihio-cluster grow justify="end">
<aihio-button variant="outline" type="reset">Clear</aihio-button>
<aihio-button type="submit">Sign in</aihio-button>
</aihio-cluster>
</form>type="submit"is forwarded to the control, which is an ordinary submit button in the form: native constraint validation runs first,Enterin a field submits, andSubmitEvent.submitteridentifies the button that did it.type="reset"resets the form.- The control is a member of
form.elementsand is disabled and re-enabled by<fieldset disabled>, natively and in both directions. nameonaihio-inputis what puts the value inFormData. Without it the field submits nothing, and both the linter and the dev build say so.- Like a native input, the host's
valueattribute is the reset/default value and itsvalueproperty is live state. Typing never writes the live value back into HTML, so serializing the element does not leak passwords or other user-entered data. SetdefaultValuewhen you intend to change both the reset value and thevalueattribute. - Native validation is exposed through
validity,validationMessage,willValidate,checkValidity(),reportValidity(), andsetCustomValidity(); the delegated control is available ascontrol. aihio-fieldgenerates the ids and wiresaria-labelledbyandaria-describedby, pointing the description at the error message while the field is erroring. Clicking either a native<label>or otherslot="label"content focuses the delegated input.
Switches and native controls
aihio-switch is the on/off setting: a real <input type="checkbox"
role="switch"> that submits name=value when on and nothing when off. Inside
aihio-field it lays out as a row, the switch beside its label:
<aihio-field>
<label slot="label">Email notifications</label>
<aihio-switch name="email-notifications" checked></aihio-switch>
<span slot="description">A digest of activity, sent weekly.</span>
</aihio-field>As with a native checkbox, the checked attribute is the reset default and
the checked property is live state. Use aihio-toggle for a pressed state
that does not submit, such as Bold in a toolbar.
Aihio leaves <textarea>, <select>, checkboxes, and radios to the platform.
Inside aihio-field a <textarea> or <select> is wired and drawn like
aihio-input, and accent-color puts native checkboxes, radios, and range
inputs in the theme.
Combobox
aihio-combobox is the field for choosing one option from a list too long to
scan, or from a search. It submits the chosen option's value, not the label
on show:
<aihio-field>
<label slot="label">Country</label>
<aihio-combobox name="country" value="fi" required>
<aihio-option value="fi">Finland</aihio-option>
<aihio-option value="se">Sweden</aihio-option>
<aihio-option value="ax">Åland Islands</aihio-option>
</aihio-combobox>
</aihio-field>- Typing filters without regard to case or accents (
alandfindsÅland Islands), ranks prefix matches first, sets the matched text in bold, and highlights the best match so Enter or Tab takes it. - The text can never disagree with the value. Leaving the field keeps an exact
match, and otherwise puts back the chosen option's label, so a field that
looks filled in is filled in. Add
allow-customto accept free text instead. - The keyboard follows the WAI-ARIA combobox pattern: arrows (with Alt), PageUp/PageDown, Enter, Escape, and Tab. DOM focus never leaves the input. Escape on a closed field is left alone, so it still closes a surrounding dialog.
- For options from a server, set
filter="none", listen foraihio-search, and replace the<aihio-option>children, togglingloadingwhile the request is in flight. A polite status region announces the result count once typing pauses;results-text,empty-text, andloading-textlocalise it.
The <aihio-option> children are the data the list is rendered from, and
they stay exactly where they were written. That is what lets React, Vue, or
Svelte own them: the framework adds and removes its own nodes, and the
component re-renders the list from them. host.replaceChildren(...options)
is fine too. The component puts back the input it would otherwise take with
it, and focus with it, so typing is not interrupted.
Dates
aihio-date-picker is the field for a date that is typed as often as it is
picked: a due date, a birth date. aihio-calendar is the same month grid on
the page, for a date chosen there, such as a delivery day. Both submit the date
as YYYY-MM-DD, as a native <input type="date"> does, and take it in that
form in value, min, and max:
<aihio-field>
<label slot="label">Due date</label>
<aihio-date-picker name="due-date" value="2026-10-30" min="2026-10-01"></aihio-date-picker>
</aihio-field>- The field reads a date the way the page's language writes one (the closest
lang):9.10.2026in Finland,10/9/2026in the US, and2026-10-09anywhere. Any separator works, and so do a month name (9 Oct 2026,9. lokakuuta), a two-digit year, no year (this one), the language's own digits, and no separators at all (09102026). The placeholder is the format, written the language's way:mm/dd/yyyy,pp.kk.vvvv,tt.mm.jjjj. - Where
langnames no region, asendoes on most English pages, the browser's region for that language fills it in.enis British English in a browser set toen-GB, where09/10/2026is 9 October, and stays American English in one that asks only forfi-FI. A region the page names (en-US) is kept, and so is its script. - Leaving the field, or Enter, commits what was typed and writes it back in full. Text that is not a date is kept, to be corrected rather than typed again, and the field is invalid until it is.
- The button opens the calendar under the field, with focus on the chosen date or today. The arrow keys move a day or a week, Home and End go to the ends of the week, Page Up and Page Down move a month (a year with Shift), and Enter picks. Escape closes it and focus goes back to the button. Alt+ArrowDown opens it from the field.
- Previous and next buttons step a month at a time, and native month and year selects jump to any of them, so a birth year is one choice away.
- The calendar follows the language and its region: the day its week starts on (Monday in Finland, Sunday in the US, Saturday in Egypt), its month and weekday names, and the arrow keys mirrored right to left. The Gregorian calendar is used whatever the language's own.
minandmaxstrike out the days outside them, keep the keyboard between them, and bound the months and years the selects offer. A date typed outside them stays the value, as with a native date input, and the field is invalid until it changes.
For anything finer (weekends, holidays, days already booked), give
isDateDisabled a function. It is called with each date shown, as
YYYY-MM-DD, and a date it returns true for is struck through and cannot be
picked:
picker.isDateDisabled = (date) => [0, 6].includes(new Date(date).getUTCDay());
// Booked days from a server, a month at a time.
picker.addEventListener('aihio-month', async ({ detail }) => {
const booked = new Set(await fetchBookedDays(detail.start, detail.end));
picker.isDateDisabled = (date) => booked.has(date);
});new Date('2026-10-10') is midnight UTC, so read the weekday with
getUTCDay(): getDay() is a day early anywhere west of Greenwich.
Month and weekday names, and the Month and Year labels, come from Intl. The
rest is in attributes for localisation: choose-text, previous-month-text,
next-month-text, and the validation messages invalid-text, min-text,
max-text, and unavailable-text. Listen for aihio-change: the native
input and change events come from the text field and carry its text, not
the date.
Dialogs
A dialog opens and closes from markup, with no script, through the platform's Invoker Commands:
<aihio-button commandfor="delete-project" command="--open" variant="destructive">
Delete project
</aihio-button>
<aihio-dialog id="delete-project">
<aihio-dialog-header>
<aihio-dialog-title>Delete project?</aihio-dialog-title>
</aihio-dialog-header>
<form method="post">
<aihio-dialog-footer>
<aihio-button commandfor="delete-project" command="--close" variant="outline">Cancel</aihio-button>
<aihio-button type="submit" variant="destructive">Delete project</aihio-button>
</aihio-dialog-footer>
</form>
</aihio-dialog>aihio-dialoganswers--open,--close, and--toggle. A--closearrives as anaihio-before-closewith reason"command", so it can still be refused, and focus goes back to the button that opened the dialog.- The commands are custom (
---prefixed) because the platform only delivers built-in ones such asshow-modalto a native<dialog>, and this one is inside the component's shadow root.command="show-modal"renders, activates, and does nothing; the linter reports it asinvalid-command, and the dev build warns when it is clicked. commandforandcommandwork onaihio-buttonand on a plain<button>. Engines without the API get the same behaviour from a small fallback.- The declared commands are part of the schema (
commands), soAihio.describe('aihio-dialog').commandslists them.
Tables
aihio-table wraps a native <table> rather than replacing it, so rows,
columns, and header cells keep the platform's table semantics, the ones screen
reader table navigation is built on, and a server-rendered table is styled and
readable before the module loads:
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" data-sortable="invoice">Invoice</th>
<th scope="col" data-sortable="issued" aria-sort="descending">Issued</th>
<th scope="col" data-sortable="amount" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="/invoices/1044">INV-1044</a></th>
<td><time datetime="2026-09-12">12 Sep 2026</time></td>
<td data-numeric>€12,400.00</td>
</tr>
</tbody>
</table>
</aihio-table>data-sortableon a<th>makes its column sortable. The header's content moves into a<button>inside the<th>, which keeps its role and carriesaria-sort, and the new order is announced in a polite status region. The first activation sorts ascending, the next descending.- Rows sort by value. A column of figures compares as numbers, read the way the
page's
langwrites them (€1,250.50,1 250,50 €); text compares in that language, with digits in numeric order (Item 2beforeItem 10); a<time datetime>sorts by its machine-readable value; anddata-sort-valuegives a cell an order its words do not have (HighbeforeLow). Rows with no value go last either way, and ties keep their order, so sorting by one column and then another is a two-level sort. aria-sortis the order. Write it on the header the rows are sorted by and the table puts them in that order when it loads; set it from script and they re-sort; add or replace rows under it and they take their place. Edits inside a row do not move it.data-numericlines a column of figures up by place value. A row's name, a figure, and a date never wrap; the table scrolls instead.- Too wide for its box, the table scrolls inside it, and the box becomes a tab
stop and a region named by the caption, so the keyboard can reach it; a
shadow marks each edge with rows scrolled past it.
sticky-headerkeeps the header in view while the rows scroll in a box up to--aihio-table-max-heighttall.
When a framework renders the rows from state, or the server sorts them, add
manual-sort. A click still marks the header, announces the sort, and fires
aihio-sort with the column's data-sortable name and the direction, but the
rows stay as rendered, for your code to put in order. Without it the table
moves its own rows: with moveBefore() where the browser has it, so focus
inside a row survives a sort, and only ever to just before another row, so a
framework that rendered them still finds them where its list starts and ends.
The table sits on the page canvas; inside aihio-card and aihio-dialog it
takes the raised surface. Anywhere else, set --aihio-table-bg to the colour
behind it, which a sticky header paints.
More rows than a page can hold
aihio-table renders every row it is given, and every row costs the browser
layout: a few thousand are comfortable, and at 100,000 a plain <table> takes
seconds to render and seconds more for each sort. There are two answers.
Pages, with aihio-pagination. Your code or the server sorts the whole
list and the table shows one page of it, with manual-sort. Everything stays
in the page: find in page, printing, and a screen reader's table navigation
all work. Prefer it when people need those.
<aihio-pagination page="3" pages="4000" href="/invoices?page={page}" aria-label="Invoice pages"></aihio-pagination>- With
hrefeach page is a link, with rel="prev" and rel="next", that can open in a new tab; without it each page is a button.aihio-pagefires first and is cancelable, so a client-side router can take over. - The current page carries
aria-current="page"and is drawn with a border at 3:1 and a heavier weight. Past seven pages the list keeps the first, the last, and the pages around the current one, so its length never changes; where that does not fit on one line it draws compact.
One scrolling list, with aihio-data-grid. Only the rows in view are in
the page, so 100,000 rows render in about 20ms and a jump to any scroll position
takes 10–30ms, but your code renders them: the grid asks with aihio-range.
<aihio-data-grid row-count="100000">
<table aria-label="Requests">
<colgroup><col style="width: 8rem"><col></colgroup>
<thead>
<tr>
<th scope="col" data-sortable="id" aria-sort="ascending">Request</th>
<th scope="col" data-sortable="path">Path</th>
</tr>
</thead>
<tbody></tbody>
</table>
</aihio-data-grid>const render = (start, end) => grid.body.replaceChildren(...rows.slice(start, end).map(renderRow));
grid.addEventListener('aihio-range', ({ detail }) => render(detail.start, detail.end));
grid.addEventListener('aihio-sort', ({ detail: { column, direction } }) => {
sortRows(column, direction); // the grid then asks for the top rows again
});
render(grid.start, grid.end); // the rows it asked for before you listened- It follows the WAI-ARIA data grid pattern: the
<table>is a grid witharia-rowcountfor every row andaria-rowindexon each rendered one, there is one tab stop, and arrow keys, Page Up and Down, Home, End, and Control+Home and End move between cells. Moving to a row that is not rendered scrolls to it, waits for your code to render it, and focuses it. - Render into
grid.body: the grid keeps a section of its own above and below it, which give the box the height of every row. Rows are one line high, and columns take their widths from<col>or the header cells, so neither changes as rows come and go. - In a framework, keep the range in state and render
rows.slice(start, end); React, Vue, and Svelte are exercised in the browser suite. Drop a server response for a range that is no longergrid.starttogrid.end. - Find in page, printing, and a screen reader's browse mode reach only the rendered rows. That is the price of the scroll, and why pages come first.
- Firefox stops a box's height at 17.9 million pixels, which is about 389,000
rows of the default height (526,000 compact); Chromium and WebKit stop at
33.5 million. Rows past it cannot be scrolled to, so lint and the dev build
warn when
row-countis above 350,000. Page longer lists.
Layout
Four primitives keep page composition inside the token system instead of in hand-written CSS:
<aihio-stack gap="lg"> <!-- vertical rhythm -->
<aihio-cluster justify="end"> <!-- horizontal groups, wraps -->
<aihio-grid columns="3"> <!-- equal columns, fewer as it narrows -->
<aihio-field> <!-- label + control + message -->aihio-grid needs no breakpoints: its columns are never narrower than 16rem,
so it drops one when it runs out of room, responding to its own width rather
than the viewport's. columns caps how many there are.
aihio-cluster is sized to its content, so add grow when you need justify
to distribute across a row inside a flex parent such as a card footer.
Events
Every event a component dispatches carries an aihio- prefix:
| Event | Dispatched by | detail |
| --- | --- | --- |
| aihio-input | aihio-input | { value } |
| aihio-change | aihio-input | { value } |
| aihio-change | aihio-combobox | { value, label } |
| aihio-change | aihio-date-picker, aihio-calendar | { value } |
| aihio-change | aihio-switch | { checked } |
| aihio-search | aihio-combobox | { query } |
| aihio-toggle | aihio-toggle | { pressed } |
| aihio-open, aihio-close | aihio-dialog, aihio-dropdown | — |
| aihio-open | aihio-combobox, aihio-date-picker | — |
| aihio-close | aihio-combobox, aihio-date-picker | { reason } |
| aihio-month | aihio-date-picker, aihio-calendar | { month, start, end } |
| aihio-before-close | aihio-dialog, aihio-dropdown | { reason } |
| aihio-select | aihio-dropdown-item | { value } |
| aihio-tab-select | aihio-tab | { value } |
| aihio-sort | aihio-table, aihio-data-grid | { column, direction } |
| aihio-range | aihio-data-grid | { start, end } |
| aihio-page | aihio-pagination | { page } |
They all bubble and are composed, which is exactly why the prefix matters: an
unprefixed close, toggle, select, or input reaching a listener higher up
the tree would be indistinguishable from the native event of the same name.
aihio-before-close is cancelable. Call preventDefault() to keep an overlay
open while, for example, an unsaved-changes confirmation is shown.
aihio-input deliberately leaves the native events alone rather than
re-dispatching them. Its inner <input> is real light DOM, so native input
and change bubble through the host on their own, with event.target.value
carrying the value. Listen to the native pair for v-model-style bindings, or
to the prefixed pair when you want detail.
Frameworks
Components are light DOM unless they need a shadow root (only aihio-dialog,
aihio-dropdown, and aihio-tabs do), every attribute is a string or boolean,
and nothing takes an object or array prop. That is the shape that survives a
virtual DOM without a wrapper layer. The one function, isDateDisabled on the
date components, is a property, and React 19, Vue, and Svelte each set it as
one.
React 19, Vue 3, and Svelte 5 are exercised in the browser test suite. Each can
set the value property and receive the native bubbling input event without
a wrapper component.
Vue templates need the tags marked as custom elements, or Vue will try to resolve them as Vue components and warn on every one:
// vite.config.js
vue({
template: {
compilerOptions: { isCustomElement: (tag) => tag.startsWith('aihio-') },
},
})After that, bind :value and update your model from @input; custom events such
as @aihio-tab-select are available directly.
Svelte needs no configuration. Use value={value} with oninput; Svelte's
bind:value directive is restricted to native form controls. Hyphenated custom
events can be attached directly.
React 19 sets recognized custom-element properties directly. value={value}
and onInput={handler} therefore work as expected.
Wrapped native controls
aihio-button, aihio-toggle, aihio-input, and aihio-switch render a real control in
their own light DOM rather than emulating one, because that is the only way a
component participates in a form the way the platform does. For the buttons
that means the children they are given are moved into the <button> when the
element upgrades:
<aihio-button>Save</aihio-button>
<!-- becomes -->
<aihio-button><button type="button">Save</button></aihio-button>Static and server-rendered markup is unaffected. Aihio also watches direct child updates and re-adopts new label nodes into the delegated control; this behavior is covered by the React, Vue, and Svelte browser harnesses. When a framework should own the complete control subtree, author the native button explicitly:
<aihio-button><button type="submit">{label}{spinner}</button></aihio-button>An authored <button> is adopted rather than duplicated, and keeps its own
type unless the host states one.
Links that look like buttons
A button does not navigate. For a call to action that goes to another page,
put an <a href> inside aihio-button: the link becomes the control and takes
the variant and size, and no <button> is created.
<aihio-button variant="outline"><a href="/pricing">See pricing</a></aihio-button>It stays a real link: announced as one, with its URL on hover, open-in-new-tab,
and navigation before the module has loaded, since the stylesheet draws it as
a button from the first paint. disabled and loading take it out of the tab
order, set aria-disabled="true", and cancel its clicks; the form and command
attributes, which configure a <button>, are not forwarded to it.
The linter steers the other shapes here. href (or a router's to) on
aihio-button is reported with the wrapped link as its suggestion, and an
onclick that sets location is an a11y-contract error.
Server rendering
Custom elements do not upgrade on the server, so SSR'd markup is live HTML
before the defining module has run. Light DOM components are styled by
aihio.css from the first paint and simply lack behaviour until hydration.
The shadow components would instead print their children inline — a dialog body
in the middle of the page, a menu spilled under its trigger — so aihio.css
holds them back until they are defined (src/css/pre-upgrade.css). Nothing is
required of the consumer, but note that if the module never loads, a dialog and
a dropdown menu stay hidden rather than appearing without behaviour.
Native overlays
aihio-dialog delegates modality to <dialog>.showModal(), including the
browser's inert outside document and top-layer backdrop. aihio-dropdown uses
a manual Popover API surface when available, with a fallback for older engines,
so Escape and outside-click close requests can still be canceled. Menus are
positioned against the trigger, flip above it when needed, and clamp to the
viewport. A shared scroll lock keeps the page locked until the final stacked
dialog closes.
Verification
npm test # unit, schema, linter, and browser fixture tests
npm run typecheck # generated declarations + JSX contract
npm run test:browsers # Chromium, Firefox, WebKit + React/Vue/Svelte harnessesCI installs all three Playwright engines and runs the complete npm run check
pipeline, including documentation and automated accessibility checks.
Tokens
Design tokens are written in the W3C Design Tokens Community Group's stable format (DTCG 2025.10) and compiled to CSS custom properties. Colors use Oklch for perceptual uniformity.
Three tiers, each a set in the
resolver,
tokens/aihio.resolver.json:
- Primitive (
tokens/base.tokens.json) — raw scales: the colour ramps, spacing, type, radius, shadow, and duration steps - Semantic (
tokens/semantic.tokens.json) — what a value is for:color.surface-bg,spacing.stack-md,radius.interactive. Every semantic colour has a light and a dark value, intokens/theme/light.tokens.jsonandtokens/theme/dark.tokens.json, the two contexts of the resolver'sthememodifier - Component (
tokens/component.tokens.json) — sizes specific to one component (button height, input height, and so on)
npm run tokens checks every value against its type and refuses anything in a
token file it does not read, rather than skipping it. Ten values need CSS the
format cannot express: the radius steps are calc() over radius.base, so one
override rounds the whole system; tracking is in em, so it scales with the
type; and two heights are capped at 75dvh. Each keeps that CSS in an
io.github.luntta.aihio extension, which is what the build writes, and carries
the nearest standard value as its $value for tools that read only that.
Components read only semantic and component tokens. Every token compiles to
--aihio-<group>-<name>, lowercase and hyphenated:
.note {
color: var(--aihio-color-muted-fg);
padding: var(--aihio-spacing-stack-sm);
border-radius: var(--aihio-radius-surface);
}That shape is a contract, enforced by a build test, because a custom property fails silently:
- Prefixed, so Aihio's names cannot collide with a host app's. shadcn/ui
defines
--primaryand--backgroundon:root, and Tailwind v4 defines--color-red-600and--radius-md, each in its own value format. - Lowercase and hyphenated, with no escapes. Half steps are written with a
hyphen (
--aihio-spacing-1-5is 0.375rem):var(--spacing-2.5)is a parse error that drops the whole declaration. - Full colour values.
var(--aihio-color-page-fg)is a colour as written. Transparency is mixed in, which works on any colour value:color-mix(in oklch, var(--aihio-color-page-fg) 40%, transparent). - Resolved. Every
var()thataihio.css, the component shadow styles, and the docs site read has to be defined.
Semantic colour names follow the schema's intent vocabulary where they meet it
(primary-action-bg for the primary-action intent), and the tier is called
semantic so that "intent" means one thing in Aihio: the vocabulary in the
schema.
The full reference ships in the package as dist/semantic-tokens.md (aihio/tokens.md)
and as dist/tokens.json (aihio/tokens), which carries every token's resolved light
and dark values, the token it reads, and the measured contrast of each pair in
the contract.
The token files ship too, as written, in dist/dtcg/
(@luntta/aihio/dtcg/aihio.resolver.json), for Style Dictionary, Terrazzo,
design tools, and anything else that reads DTCG.
Palette
The neutral ramp is monochrome — hue 286 at near-zero chroma — and runs to true
white (zinc.0) and true black (zinc.1000), so a page canvas is untinted in
both themes rather than a very light or very dark grey.
Light and dark are built as mirror images rather than as two separate palettes: a pure canvas, an elevated surface one step off it, and a hairline that only just separates the two.
| Token | Light | Dark |
| --- | --- | --- |
| --aihio-color-page-bg | zinc.0 (#ffffff) | zinc.1000 (#000000) |
| --aihio-color-surface-bg | zinc.50 (#f5f5f7) | zinc.950 (#161617) |
| --aihio-color-border-subtle | zinc.200 | zinc.850 |
| --aihio-color-overlay-highlight-bg | zinc.100 | zinc.800 |
overlay-highlight-bg marks the active item inside an overlay. It exists
because control-highlight-bg and the overlay surface are the same zinc.900
in the dark theme, so a highlight drawn with it would be invisible there.
Because the elevated surface differs from the canvas in both themes, cards, alerts, and dialogs read as raised without needing a shadow to prove it.
Typography
The type stack (--aihio-font-family-sans) leads with the platform UI face, so
text renders in the grey the OS already optimises for and no webfont ships —
the package stays zero-dependency.
Letter spacing tightens as type grows (display -0.025em, heading -0.018em,
body -0.011em), because the same em value reads looser at larger sizes.
label is the one positive value, reserved for small capitalised labels.
Components read these through --aihio-letter-spacing-* rather than
hardcoding a tracking value.
Contrast
src/tokens/build.js checks the palette against a contract of foreground and
background pairs — 4.5:1 for body-size text, 3:1 for the boundary of an
interactive control — and fails the build if any pair regresses. Card and alert
borders are deliberately excluded: they are decorative framing, not the kind of
boundary WCAG 1.4.11 covers, and holding them to 3:1 would make every surface
in the system shout. Field borders are covered, because on an empty input the
border is the only thing marking the control.
Add or change a pair in CONTRAST_REQUIREMENTS in src/tokens/contrast.js.
It currently checks 17 pairs per theme, 34 in total.
State colours
The palette carries four state intents — neutral, success, warning, and
destructive — available on aihio-alert and aihio-badge. Warning pairs a
light amber fill with a dark foreground in both themes, because an amber that
passes 4.5:1 against white text is no longer amber.
There is no separate info colour. Neutral is the informational state, which
keeps the palette to what the system can actually keep accessible.
Motion
Component transitions are timed from --aihio-duration-*, and
@media (prefers-reduced-motion: reduce) collapses those tokens to 1ms — one
place, no !important, and consumer animation is left alone. Looping and
entrance keyframes are switched off at their own declaration instead, since a
1ms infinite rotation is a strobe. A build test fails if a component gains an
animation without a reduced-motion guard.
Forced colours
Under forced-colors: active the OS replaces backgrounds and text colours, so
anything whose state reads only as a fill disappears — a pressed toggle, a
selected tab, a filled button. Every such state is restated with system colour
keywords (ButtonFace/ButtonText, Highlight/HighlightText, GrayText,
FieldText), and the input error state widens its border rather than relying
on colour at all.
Dark mode
Dark mode works automatically via prefers-color-scheme, or manually, on the
page or on any element for its subtree:
<html data-theme="dark">
<section data-theme="light">…</section>color-scheme follows the theme, so the native controls Aihio leaves to the
platform (<select>, <textarea>, checkboxes, scrollbars) are drawn for the
same theme as everything around them.
Customization
Override custom properties to theme the whole system. Untinted tokens need one declaration; colours are themed, so set them for each theme:
:root {
--aihio-radius-base: 0.75rem; /* every radius step is computed from it */
--aihio-color-primary-action-bg: oklch(0.55 0.2 260);
}
:root[data-theme="dark"] {
--aihio-color-primary-action-bg: oklch(0.72 0.15 260);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--aihio-color-primary-action-bg: oklch(0.72 0.15 260);
}
}Docs Site
The design system docs site is built with Eleventy and generated from the same emitted schema and token artifacts that ship in dist/.
npm run docs:build # Build the package and the Eleventy docs site into _site/
npm run docs:dev # Watch dist/ + docs source and serve the Eleventy siteThe site uses Aihio components in the docs UI itself and currently includes:
- Overview, the intent vocabulary, and an AI and tooling guide (MCP setup, the tools, the lint loop, and every file an agent can read)
- A component index drawn live, and a page per component: variants, titled examples, each mistake beside its fix with the linter's output, the full API including sub-components, and the accessibility contract
- The pattern library
- Foundations: colour (with the contrast contract), typography, space and shape, motion, and a token reference
- Search, ranked by the same function as the MCP
findtool llms.txt,llms-full.txt, and every component page as markdown
The docs checks fail on broken links or fragments, repeated ids, a dialog
rendered open, and any markup outside an example preview that aihio lint
rejects.
Development
npm install
npm run dev # Dev server at localhost:3000/
npm run build # Build dist/ (production + dev bundles)
npm run docs:build # Build the Eleventy docs site into _site/
npm run docs:test # Validate generated docs HTML assumptions
npm run test # Rebuild dist/ and run node and headless browser checks
npm run check # Run tests, build docs, and validate the docs output
node dist/cli.js mcp # Start the local MCP server over stdio
npm run tokens # Rebuild tokens and their references (also enforces the token format and the contrast contract)
npm run styles # Rebuild generated component CSS
npm run schema # Rebuild schema onlyLicense
MIT
