npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@luntta/aihio

v2.5.0

Published

AI-first design system built on native web components. Zero dependencies.

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/aihio

Usage

<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 installed

The 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, Enter in a field submits, and SubmitEvent.submitter identifies the button that did it. type="reset" resets the form.
  • The control is a member of form.elements and is disabled and re-enabled by <fieldset disabled>, natively and in both directions.
  • name on aihio-input is what puts the value in FormData. Without it the field submits nothing, and both the linter and the dev build say so.
  • Like a native input, the host's value attribute is the reset/default value and its value property 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. Set defaultValue when you intend to change both the reset value and the value attribute.
  • Native validation is exposed through validity, validationMessage, willValidate, checkValidity(), reportValidity(), and setCustomValidity(); the delegated control is available as control.
  • aihio-field generates the ids and wires aria-labelledby and aria-describedby, pointing the description at the error message while the field is erroring. Clicking either a native <label> or other slot="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 (aland finds Å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-custom to 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 for aihio-search, and replace the <aihio-option> children, toggling loading while the request is in flight. A polite status region announces the result count once typing pauses; results-text, empty-text, and loading-text localise 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.2026 in Finland, 10/9/2026 in the US, and 2026-10-09 anywhere. 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 lang names no region, as en does on most English pages, the browser's region for that language fills it in. en is British English in a browser set to en-GB, where 09/10/2026 is 9 October, and stays American English in one that asks only for fi-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.
  • min and max strike 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-dialog answers --open, --close, and --toggle. A --close arrives as an aihio-before-close with 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 as show-modal to 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 as invalid-command, and the dev build warns when it is clicked.
  • commandfor and command work on aihio-button and 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), so Aihio.describe('aihio-dialog').commands lists 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-sortable on a <th> makes its column sortable. The header's content moves into a <button> inside the <th>, which keeps its role and carries aria-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 lang writes them (€1,250.50, 1 250,50 €); text compares in that language, with digits in numeric order (Item 2 before Item 10); a <time datetime> sorts by its machine-readable value; and data-sort-value gives a cell an order its words do not have (High before Low). 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-sort is 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-numeric lines 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-header keeps the header in view while the rows scroll in a box up to --aihio-table-max-height tall.

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 href each 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-page fires 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 with aria-rowcount for every row and aria-rowindex on 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 longer grid.start to grid.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-count is 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 harnesses

CI 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, in tokens/theme/light.tokens.json and tokens/theme/dark.tokens.json, the two contexts of the resolver's theme modifier
  • 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 --primary and --background on :root, and Tailwind v4 defines --color-red-600 and --radius-md, each in its own value format.
  • Lowercase and hyphenated, with no escapes. Half steps are written with a hyphen (--aihio-spacing-1-5 is 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() that aihio.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 site

The 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 find tool
  • 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 only

License

MIT