CoreUI is a brand-neutral Web Component library and design utility kit for the dappco.re polyglot stack. Lit-based, light DOM, oklch-first, Tailwind v4 friendly. Brandable with one attribute.
<body data-brand="lethean" data-mode="dark">
<button class="bg-brand-500 text-fg-0 rounded-md font-sans shadow-2">
Save
</button>
</body>- Brand-neutral by default. Three opt-in brands ship in the box (hostuk, lethean, ofm). New brands are one CSS file each — no central registry.
- Tailwind v4 ready.
import '@dappcore/ui/tokens/tailwind'andbg-brand-500,rounded-md,font-sans,shadow-2work natively, with[data-brand]switching flowing through. - Oklch-first colour helpers. Parse, convert, rotate, mix, contrast — agents building canvas/SVG/charts don't reach to npm for one util.
- ReactiveController patterns. Focus-trap, click-outside, resize/intersection/mutation observers, brand/mode controllers — Lit-aware, lifecycle-correct.
- a11y baked in. aria-live announcer, focus save/restore, prefers-reduced-motion / contrast / color-scheme reactive controllers.
- Pipe registry — shared by reference with
dappco.re/go/html; byte-identical output across browser + Go server + PHP server.
# npm
npm install @dappcore/ui
# git submodule (dappco.re-native pattern)
git submodule add https://github.com/dAppCore/ui.git external/ui- Source:
dappco.re/ui(canonical),github.com/dAppCore/ui(mirror) - Docs: https://core.help (end-user facing, un-branded help + technical guides)
- Package:
@dappcore/ui - Tag prefix:
<core-*>
import '@dappcore/ui'; // everything (JS)
import '@dappcore/ui/tokens'; // CSS tokens — brand-neutral
import '@dappcore/ui/tokens/tailwind'; // Tailwind v4 @theme bridge
import '@dappcore/ui/tokens/brand-lethean'; // one brand on demand
import { parseColour, mix, contrastRatio } from '@dappcore/ui/colour';
import { Easing, interpolate, clamp } from '@dappcore/ui/math';
import { FocusTrap, matchKey } from '@dappcore/ui/dom';
import { announce, generateId } from '@dappcore/ui/a11y';
import { getPlatform, isNativeShell } from '@dappcore/ui/platform';
import { BrandController, ModeController } from '@dappcore/ui/brand';Eleven brand-neutral Web Components ready for any UI agent:
<core-button variant="primary" type="submit">Save</core-button>
<core-toggle name="notify" value="yes" checked>Notify me</core-toggle>
<core-status-dot state="good" pulse aria-label="Online"></core-status-dot>
<core-pill state="brand">
<core-icon slot="leading" name="check" decorative></core-icon>
Active
</core-pill>
<core-icon name="search" size="lg"></core-icon>
<core-label for="email" required>Email</core-label>
<core-card elevation="raised" interactive>Body content</core-card>
<core-glass dark radius="20px"><p>Floating panel</p></core-glass>
<core-window-controls></core-window-controls> <!-- auto-detects -->
<core-rail href="/dashboard" active>Dashboard</core-rail>
<core-sparkline kind="area" points="1,3,2,5,4,7,6"></core-sparkline>Light DOM, ::part()-style hooks via attribute selectors, brandable via
[data-brand] (all primitives consume --core-* tokens). Default styles
ship as sibling .css files; import the aggregator for one-shot setup:
@import "@dappcore/ui/primitives/index.css";The <core-icon> registry ships 12 default icons (check, x, chevrons,
plus/minus, info/warning/danger, search). Register your own with
registerIcon(name, svg) or drop SVG inline via the default slot.
Six form-input Web Components with native <form> participation:
<form action="/v1/sign-up" method="POST">
<core-label for="email" required>Email</core-label>
<core-input id="email" type="email" name="email" required>
<span slot="hint">Your work email</span>
</core-input>
<core-label for="password">Password</core-label>
<core-input id="password" type="password" name="password" required minlength="8">
<span slot="error">At least 8 characters.</span>
</core-input>
<core-radio-group name="plan" value="free" required>
<core-radio value="free">Free</core-radio>
<core-radio value="pro">Pro</core-radio>
<core-radio value="enterprise">Enterprise</core-radio>
</core-radio-group>
<core-checkbox name="terms" required>
I accept the terms of service
</core-checkbox>
<button type="submit">Sign up</button>
</form>Shadow DOM (RFC §4 exception for slot distribution); full Constraint Validation
API surface — setValidity, validity, validationMessage, willValidate,
checkValidity, reportValidity, setCustomValidity. Inner native inputs
carry real browser validity; the host mirrors to ElementInternals so
<form>.checkValidity() walks every custom element correctly.
Skin via real ::part() pseudo-element (Shadow DOM, unlike the v0.5 primitives'
attribute-selector workaround). Tokens still cascade — CSS custom properties
pierce shadow boundaries.
Icons via attribute lookup: <core-input leading-icon="search"> resolves
through the v0.5 icon registry. Hint and error content via <slot name="hint">
and <slot name="error">.
Four overlay Web Components — dialog, drawer, popover, tooltip:
<!-- Modal dialog with header/footer slots -->
<core-button id="delete-trigger">Delete item</core-button>
<core-dialog modal size="md" closedby="closerequest">
<h2 slot="header">Confirm deletion</h2>
<p>This action cannot be undone.</p>
<div slot="footer">
<core-button data-core-close>Cancel</core-button>
<core-button onclick="this.closest('core-dialog').close('confirm')">Delete</core-button>
</div>
</core-dialog>
<!-- Edge drawer, end side -->
<core-drawer modal side="end" closedby="any">
<h2 slot="header">Cart (3 items)</h2>
<!-- body content -->
<div slot="footer"><core-button>Checkout</core-button></div>
</core-drawer>
<!-- Anchored popover (menu) -->
<core-button id="more-btn">More</core-button>
<core-popover anchor="#more-btn" placement="bottom-start" offset="8">
<ul>
<li><button>Edit</button></li>
<li><button>Delete</button></li>
</ul>
</core-popover>
<!-- Hover/focus tooltip with auto aria-describedby -->
<core-button id="save-btn" aria-label="Save">💾</core-button>
<core-tooltip anchor="#save-btn" placement="top" delay-in="700">
Save (⌘S)
</core-tooltip>Shadow DOM. Platform-API-first (<dialog>, Popover API, CSS Anchor Positioning with
JS fallback for Safari/Firefox). Zero deps beyond Lit.
State machine (data-state="closed|opening|open|closing") on all four components —
CSS targets :host([data-state="opening"]) for transition choreography.
prefers-reduced-motion guard resets transitions to none.
closedby="any|closerequest|none" polyfill on all surfaces. [data-core-close]
close-button convention — any descendant with that attribute closes the surface on
click. Focus restored to pre-open activeElement on close.
Two abstract base classes for extension: CoreOverlayElement (dialog+drawer),
CoreAnchoredElement (popover+tooltip).
<core-data-table> + <core-column> — the data-presentation tier. Shadow DOM host, declarative light-DOM columns, zero deps beyond Lit.
<core-data-table>
<core-column key="name" label="Name"></core-column>
<core-column key="email" label="Email"></core-column>
</core-data-table>
<script>
document.querySelector('core-data-table').rows = [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
];
</script><core-data-table>
<core-column key="name" label="Name" sortable></core-column>
<core-column key="score" label="Score" sortable type="number" align="end"></core-column>
<core-column key="joined" label="Joined" sortable type="date"></core-column>
</core-data-table>Click any sortable header: tri-state cycle asc → desc → unsorted. Cancel the built-in sort by calling event.preventDefault() on core-sort-change and assigning el.rows from server data.
<core-data-table page-size="10">
<core-column key="name" label="Name" sortable></core-column>
</core-data-table>Default pagination footer: range display + prev/next + windowed page buttons. Replace entirely with <slot name="pagination"> for custom widgets.
<core-data-table
selection="multi"
page-size="25"
density="compact"
key-field="id"
>
<core-column key="name" label="Name" sortable></core-column>
<core-column key="active" label="Active" type="boolean"></core-column>
<div slot="empty">No results match your filter.</div>
</core-data-table>
<script>
const el = document.querySelector('core-data-table');
el.addEventListener('core-selection-change', (e) => {
console.log('selected:', e.detail.selected);
});
</script>Density: comfortable | cozy (default) | compact. Select-all header checkbox with tri-state (none/some/all). clearSelection(), selectAll(), selectNone() methods. Direct el.selected = new Set([...]) assignment.
<core-data-table key-field="id">
<core-column key="name" label="Name"></core-column>
<core-column key="actions" label=""></core-column>
</core-data-table>
<script>
import { html } from 'lit';
document.querySelector('core-column[key="actions"]').cellRender = (row) =>
html`<button @click=${() => editUser(row.id)}>Edit</button>`;
</script>column.cellRender receives (row, { rowIndex, columnIndex, isSelected }). Return a Lit TemplateResult, a string, or undefined (falls back to type-aware default). Custom sort comparator: column.sortFn = (a, b, dir) => ....
Note: The property is
cellRender(notrender) to avoid colliding with LitElement's render method. Spec usesrenderfor ergonomics but the implementation usescellRender.
Sticky header is on by default. Loading state: <core-data-table loading>. ARIA: role="table", aria-rowcount, aria-rowindex, aria-sort, aria-selected. Keyboard nav: ArrowUp/Down/Home/End on rows, Space toggles selection, Enter fires core-row-click, Enter/Space on sortable headers triggers sort.
<core-tabs> + <core-tab> + <core-tabpanel> — W3C ARIA APG tablist pattern. Auto-wired ARIA, roving tabindex, sliding indicator, keyboard nav.
<core-tabs>
<core-tab>General</core-tab>
<core-tab>Account</core-tab>
<core-tab>Security</core-tab>
<core-tabpanel>General settings here.</core-tabpanel>
<core-tabpanel>Account settings.</core-tabpanel>
<core-tabpanel>Security settings.</core-tabpanel>
</core-tabs><core-tabs>
<core-tab for="general">General</core-tab>
<core-tab for="billing" disabled>Billing</core-tab>
<core-tabpanel id="general">General settings.</core-tabpanel>
<core-tabpanel id="billing">Billing (disabled in nav).</core-tabpanel>
</core-tabs><core-tabs orientation="vertical">
<core-tab>Profile</core-tab>
<core-tab>Preferences</core-tab>
<core-tabpanel>Profile content.</core-tabpanel>
<core-tabpanel>Preferences content.</core-tabpanel>
</core-tabs><core-tabs activation="manual">
<core-tab>Heavy A</core-tab>
<core-tab>Heavy B</core-tab>
<core-tabpanel>Expensive panel A.</core-tabpanel>
<core-tabpanel>Expensive panel B.</core-tabpanel>
</core-tabs>Arrow keys move focus only. Space or Enter activates the focused tab. Best for panels with heavy content.
<core-tabs>
<core-tab>Active</core-tab>
<core-tab disabled>Locked</core-tab>
<core-tab>Also active</core-tab>
<core-tabpanel>Panel one.</core-tabpanel>
<core-tabpanel>Locked panel.</core-tabpanel>
<core-tabpanel>Panel three.</core-tabpanel>
</core-tabs>Disabled tabs have aria-disabled="true", are skipped in keyboard nav, and ignore clicks.
const tabs = document.querySelector('core-tabs');
tabs.selectedIndex; // current index
tabs.selectedTab; // CoreTab | null
tabs.selectedPanel; // CoreTabpanel | null
tabs.select(2); // activate by index
tabs.select(elTab); // activate by element ref
tabs.refresh(); // re-read children after dynamic insert
tabs.addEventListener('core-tab-change', (e) => {
if (needsConfirm) e.preventDefault(); // block activation
});import '@dappcore/ui/tabs'; // side-effect, registers all 3 elements
import { CoreTabs } from '@dappcore/ui/tabs'; // typed<core-menu> + <core-menuitem> + <core-menu-separator> — W3C ARIA menu pattern. Shadow DOM container, auto-wired ARIA, roving tabindex, keyboard nav (Arrow/Home/End/Enter/Space/Escape), single-char type-ahead, submenu support.
<core-menu>
<core-menuitem>Dashboard</core-menuitem>
<core-menuitem>Profile</core-menuitem>
<core-menuitem disabled>Admin (disabled)</core-menuitem>
</core-menu><core-menu>
<core-menuitem value="new">
<core-icon slot="start" name="plus"></core-icon>
New file
<span slot="end" class="shortcut">⌘N</span>
</core-menuitem>
<core-menuitem value="open">Open</core-menuitem>
<core-menu-separator></core-menu-separator>
<core-menuitem value="save">
<core-icon slot="start" name="save"></core-icon>
Save
<span slot="end" class="shortcut">⌘S</span>
</core-menuitem>
</core-menu><core-menu>
<core-menuitem>New file</core-menuitem>
<core-menuitem has-submenu>Export
<core-menu>
<core-menuitem value="pdf">As PDF</core-menuitem>
<core-menuitem value="html">As HTML</core-menuitem>
</core-menu>
</core-menuitem>
</core-menu>Keyboard: ArrowRight opens submenu + focuses first item. ArrowLeft / Escape closes back to parent.
<button id="more-btn">More ▾</button>
<core-popover anchor="#more-btn" placement="bottom-start">
<core-menu>
<core-menuitem value="edit">Edit</core-menuitem>
<core-menuitem value="delete">Delete</core-menuitem>
</core-menu>
</core-popover><core-popover> (v0.8) handles anchor positioning + light-dismiss + focus. Consumer listens for core-menu-select (action) and core-popover-close (dismissal). Call popover.hide() from the core-menu-select handler to close after action.
import '@dappcore/ui/menu';
import type { CoreMenu } from '@dappcore/ui/menu';
const menu = document.querySelector('core-menu') as CoreMenu;
// Focus the first item when menu opens
menu.focusFirst();
// Focus a specific item by index
menu.focusItem(2);
// Focus a specific item by element ref
const item = menu.querySelector('core-menuitem[value="save"]');
menu.focusItem(item);
// Listen for selection
menu.addEventListener('core-menu-select', (e) => {
const { item, index, value } = e.detail;
console.log(`Selected: ${value} at index ${index}`);
});
// Listen for close request
menu.addEventListener('core-menu-close', (e) => {
// e.preventDefault() keeps menu open (e.g. for async validation)
popover.hide();
});import '@dappcore/ui/menu'; // side-effect: registers all 3 elements
import { CoreMenu } from '@dappcore/ui/menu'; // typed
import { CoreMenuitem } from '@dappcore/ui/menu/menuitem';
import { CoreMenuSeparator } from '@dappcore/ui/menu/menu-separator';<core-toast> + <core-toast-region> + toast helper — notification toasts with 4 severity levels, 6 corner positions, auto-dismiss + pause-on-hover, sticky mode, action slot.
import { toast } from '@dappcore/ui/toast';
// Severity shortcuts — singleton region created automatically in top-right
toast.success('File saved.');
toast.error('Upload failed.');
toast.warning('Unsaved changes will be lost.');
toast.info('New version available.');import { toast } from '@dappcore/ui/toast';
// Auto-dismiss after 3 seconds
toast.success('Saved!', { duration: 3000 });
// Sticky — must be manually dismissed
const id = toast.error('Network error.', { duration: 0 });
// Programmatic dismiss
toast.dismiss(id);
// Clear all
toast.dismissAll();<core-toast-region position="top-right">
<core-toast severity="info" duration="5000">
Your session expires in 5 minutes.
</core-toast>
</core-toast-region><core-toast-region position="top-right">
<core-toast severity="error" duration="0">
Upload failed.
<button slot="action" onclick="retryUpload()">Retry</button>
</core-toast>
</core-toast-region>Action button click does not auto-dismiss the toast — consumer handles dismiss in the click handler if desired.
import { toast } from '@dappcore/ui/toast';
// Target a custom region by reference
const region = document.querySelector('core-toast-region');
toast.show('Message from bottom!', { region, severity: 'info' });<!-- Six available positions -->
<core-toast-region position="bottom-center">…</core-toast-region>
<!-- top-left | top-center | top-right | bottom-left | bottom-center | bottom-right -->Bottom regions use flex-direction: column-reverse so new toasts appear nearest the viewport edge and stack inward.
<core-toast severity="warning" duration="0">
<svg slot="icon" viewBox="0 0 16 16">
<!-- your icon markup — inherits currentColor -->
</svg>
Custom icon warning.
</core-toast>Slot icon overrides the built-in severity SVG. Built-in icons: filled circle (info), checkmark circle (success), triangle (warning), × circle (error). All in currentColor.
| Severity | role on [part="toast"] |
Live region |
|---|---|---|
info |
status |
polite |
success |
status |
polite |
warning |
alert |
assertive |
error |
alert |
assertive |
<core-toast-region> sets role="region" + aria-label="Notifications" automatically.
import '@dappcore/ui/toast'; // side-effect: registers both elements
import { toast } from '@dappcore/ui/toast'; // programmatic helper
import { CoreToast } from '@dappcore/ui/toast/toast';
import { CoreToastRegion } from '@dappcore/ui/toast/toast-region';
import { toast } from '@dappcore/ui/toast/toast-helper';RFC.md — full spec including the pipe registry, component contracts, polyglot story. Read this for the why.
docs/superpowers/specs/ — incremental specs (v0.2 utils, future tracks).
See RFC.md §16. Currently at v0.7 — forms layer (six form-input Web Components with native <form> participation, on top of the v0.5 primitives + v0.2 utils foundations). Next ships the seed <core-data-table> per RFC.md §16.