@viax.io/uxm
v4.48.1
Published
Viax UXM — React 19 UI primitives and design tokens.
Downloads
730
Readme
@viax.io/uxm
React 19 UI primitives, design tokens, and theming previews — packaged as a standalone library.
BEM-classed components, a single design-token layer, and themable previews. Framework-agnostic React 19 — distributed as ESM + CJS with first-class .d.ts, no runtime dependencies beyond React, and sideEffects: ["**/*.css"] for full tree-shaking.
The package ships no "use client" / "use server" directives by design: consumers are client-side SPAs, and framework-specific pragmas would leak into every one of them. Under a React Server Components setup (e.g. Next.js App Router) the consuming file is responsible for its own "use client" boundary.
What's in this package
- UI primitives (
@viax.io/uxm/ui) — BEM-classed React 19 components. - Icon registry (
@viax.io/uxm/ui) —ICONS,ICON_OPTIONS,getIcon,IconDeffor tooling that enumerates the bundled icon set. - Design tokens (
@viax.io/uxm/tokens) — the canonicalthemeTokensarray plus helpers (findToken,resolveHex,isTokenValue) and theThemeTokentype. - Behaviour hooks (
@viax.io/uxm/hooks) —useDismiss,useFocusTrap,useFocusOnMount,useRovingTabIndex,useScrollLock,usePortal,useToastStorefor hosts composing their own floating layers or keyboard widgets. - Themable previews (
@viax.io/uxm/previews) — preview components (one per atom, plus composite previews) used by host shells like MODO's brand-settings editor to render live, knob-driven theme exploration. - Studio (
@viax.io/uxm/studio) —UxmApp, the full design workbench MODO serves at/uxm, backend-decoupled through theStudioPersistencecontract; see Studio. - WCAG / contrast helpers (
@viax.io/uxm) —contrastRatio,parseColor,rgbToHex,suggestAccessibleColor,suggestAccessibleToken,wcagLevel, plusRGBandTokenCandidatetypes. - Default stylesheets —
@viax.io/uxm/ui.css(component primitive defaults) and@viax.io/uxm/tokens.css(token declarations).
Install
npm i @viax.io/uxmEvery example in this README imports from @viax.io/uxm and its subpaths.
Tip: install under an alias. viax apps install the package as
uxmso the real name appears once, inpackage.json, and imports stay short and rename-proof:npm i uxm@npm:@viax.io/uxm@latestThat writes
"uxm": "npm:@viax.io/uxm@^4.40.1"and lets you writeimport { Button } from 'uxm/ui'orimport 'uxm/tokens.css'. The subpath exports are identical under either name — read@viax.io/uxmasuxmthroughout.
Peer deps: react@^19, react-dom@^19. Node >=20 for local dev.
Published publicly on npm — no registry configuration, credentials or VPN required for a plain
npm i @viax.io/uxm against the default npm registry.
If your project's default registry points at Nexus (
registry=https://nexus.viax.tech/repository/viax-npm/in.npmrc— the common viax-internal setup), add a scope override so this package still resolves straight from npm:@viax.io:registry=https://registry.npmjs.org/Nexus mirrors
registry.npmjs.orgwith a delay, so installing right after a fresh release can 404 or silently resolve a stale version through Nexus without this override.
Quick start
Import both stylesheets once, at your application entry point:
// e.g. src/main.tsx
import '@viax.io/uxm/tokens.css';
import '@viax.io/uxm/ui.css';Then use the primitives anywhere:
import { ButtonPrimary, Icon } from '@viax.io/uxm/ui';
export function Page() {
return (
<ButtonPrimary>
<Icon glyph="sparkles" /> Hello UXM
</ButtonPrimary>
);
}Consume tokens programmatically:
import { themeTokens, findToken, resolveHex } from '@viax.io/uxm/tokens';
const accent = findToken('--color-accent-bold');
const hex = resolveHex(accent?.cssVar ?? '#000');Both primary entry points are also re-exported from the root:
import { ButtonPrimary, themeTokens } from '@viax.io/uxm';Subpath exports
| Entry | Purpose |
|-------|---------|
| @viax.io/uxm | Root barrel — re-exports ui + tokens + WCAG helpers. |
| @viax.io/uxm/ui | All UI primitives + icon registry. Tree-shake-friendly per-component imports. |
| @viax.io/uxm/ui.css | Compiled component stylesheet — required for visual output. |
| @viax.io/uxm/tokens | themeTokens array + findToken / resolveHex / isTokenValue + ThemeToken type. |
| @viax.io/uxm/tokens.css | --color-* declarations on :root. |
| @viax.io/uxm/hooks | The behaviour hooks the atoms are built on — useDismiss, useFocusTrap, useFocusOnMount, useRovingTabIndex, useScrollLock, usePortal, useToastStore — for hosts composing their own floating layers or keyboard widgets. Pure React, no atom imports. |
| @viax.io/uxm/previews | Preview components for host shells building theme editors. No preview symbol leaks into /ui — see the tree-shake guarantee below. |
| @viax.io/uxm/studio | UxmApp + UxmProvider / useUxm, the component registry, the StudioPersistence contract and its three adapters (createHttpPersistence, createClientPersistence, createReadOnlyPersistence). |
| @viax.io/uxm/studio/generate-css | generateOverridesCss + the CSS sanitizers — turn saved studio overrides and a BrandConfig into a stylesheet on the server, without pulling in the workbench UI. |
| @viax.io/uxm/studio.css | Tailwind utilities for the studio shell + token declarations. Does not bundle the atom CSS — a studio host imports ui.css alongside it. |
CDN usage
For pages that can't run npm at all — prototypes, CodePen repros, embeds in someone else's site — every release also publishes a handful of self-contained <script>/<link>-ready bundles, built by npm run build:cdn and uploaded automatically on release.
If your project has a bundler, this is not what you want. Use
npm i @viax.io/uxm— the CDN bundles below exist specifically for pages that can't.
All files live at https://uxm.viax.io/<version>/<file> — versioned and immutable (Cache-Control: public, max-age=31536000, immutable). There is no /latest/: pin an exact version, and use the same version across every file you load on one page.
| File | Format | React | For |
|---|---|---|---|
| uxm.esm.js | ESM, react / react-dom / react/jsx-runtime external | the host page's, via import map | pages that already have React 19 |
| uxm.standalone.js | IIFE, window.UXM, React bundled in | its own, inside the bundle | pages with no React at all |
| uxm.css | flattened tokens.css + ui.css | — | both |
| uxm-generate-css.esm.js | ESM, no deps | — | only if you need generateOverridesCss (the theming applier) |
| uxm.esm.d.ts / uxm-generate-css.esm.d.ts | bundled .d.ts per JS artifact | needs @types/react | editor/type-check support |
| cdn-manifest.json | byte size, gzip size, sha384 SRI hash per file | — | verify what you fetched |
⚠️ React 19 ships no UMD build. The classic "two <script> tags from a CDN plus our UMD on top" isn't possible — that's why there are two separate formats above instead of one.
ESM — page that already has React 19
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19",
"react-dom/client": "https://esm.sh/react-dom@19/client",
"@viax.io/uxm/ui": "https://uxm.viax.io/4.43.0/uxm.esm.js"
}
}
</script>
<link rel="stylesheet" href="https://uxm.viax.io/4.43.0/uxm.css" />
<script type="module">
import { ButtonPrimary } from '@viax.io/uxm/ui';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(createElement(ButtonPrimary, null, 'Hello UXM'));
</script>Standalone — page with no React
<link rel="stylesheet" href="https://uxm.viax.io/4.43.0/uxm.css" />
<script src="https://uxm.viax.io/4.43.0/uxm.standalone.js"></script>
<script>
const { React, createRoot, ButtonPrimary } = window.UXM;
createRoot(document.getElementById('root')).render(React.createElement(ButtonPrimary, null, 'Hello UXM'));
</script>⚠️ Never load uxm.standalone.js on a page that already has another React instance — two copies of React break hooks and context. If the page has React, use the ESM build above instead.
Required host baseline
A CDN page gets none of the resets a bundler-based app inherits from its own global stylesheet. Add this once, after uxm.css — skip it and the app renders in Times New Roman with inputs overflowing their containers. This is the same baseline npm consumers need; see skills/viax-uxm/references/quick-recipes.md (recipe 0) for the full version and why each line is load-bearing.
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; }
:root { --font-sans: var(--font-inter, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif); }
html, body, #root { font-family: var(--brand-font, var(--font-sans)); }
button, input, select, textarea { font: inherit; }Types
The .d.ts bundles need @types/react in your project — they extend React's own prop types (ButtonHTMLAttributes and friends), which aren't ours to inline. Fetch one alongside your CDN import and map it in tsconfig.json:
UXM=4.43.0
curl -fsSL -o types/uxm.esm.d.ts https://uxm.viax.io/$UXM/uxm.esm.d.ts{ "compilerOptions": { "paths": { "@viax.io/uxm/ui": ["./types/uxm.esm.d.ts"] } } }If your project can take even a dev-only npm dependency, npm i -D @viax.io/uxm@<version> is simpler and gives every subpath's types at once — the curl route above exists for projects that can't.
Verifying what you fetched (SRI)
cdn-manifest.json next to every version's files lists a sha384 hash per file — pass it as integrity on the <script> tag:
<script
type="module"
src="https://uxm.viax.io/4.43.0/uxm.esm.js"
integrity="sha384-X5wmfaVzW+An31gN2SpznF1F6UxTbD4SsCynzec/bElec2EneSTZf2l0O81YHLVt"
crossorigin="anonymous"
></script>Component catalog
Every component folder ships a README.md documenting props, CSS variables, MODO-configurable design tokens, states/variants, and accessibility. Click through any name for the full reference.
The catalog below covers every folder in
src/ui/. If you add a component, add it here too — and keepsrc/ui/the authoritative source if the two ever drift.
Forms & inputs
calendar · checkbox · code-editor (CodeEditor + CodeBlock) · color-input (ColorInput + ColorInputPopover) · currency-input · date-input · editable-cell · field-error · file-upload · form-field · input (TextInput + Select + Textarea) · input-with-icon · number-input · number-stepper · password-input · phone-input · pill-select · radio-group · range-slider · search-dropdown · slider · time-input · toggle-switch
Buttons & actions
back-link · bulk-action-bar · button (Primary/Secondary/Tertiary/Ghost) · button-group · button-icon (deprecated → icon-button variant="filled") · button-with-icon · icon-button · inline-action · link
Navigation
app-sidebar · app-top-bar · breadcrumb · filter-tabs · menu · sidebar-nav-item · sidebar-nav-trigger · tabs · tabs-underline · view-switcher
Overlays & floating layers
dialog · hover-tooltip · listbox (Listbox + MultiListbox) · modal · option-list · popover · toast (Toast + Toaster + the toast.* API)
Feedback & status
badge · banner · chip · empty-state · error-page · loader · progress-bar · tag · tooltip (Tooltip + ContentTooltip)
Layout & structure
card · cluster · detail-section · divider · inline-filter · page-header · page-shell · responsive-grid · section-header · side-flexpane · stack
Data display
avatar · data-table · disclosure · icon · icon-tile · list (List + ListItem) · meta-row · property-field (PropertyField + PropertyGrid) · stat-card · thumbnail · timeline-entry · type-overview-card
Configuration editor
component-row · config-component-row · config-segment-item · explorer-list-item · explorer-section · segment-card · segment-row
Localisation
language-switcher · locale (UxmLocaleProvider + useUxmLocale)
Lifecycle diagrams
lifecycle-connector · lifecycle-drop-slot · lifecycle-edge-label · lifecycle-group-box · lifecycle-minimap · lifecycle-node-card · lifecycle-terminal · lifecycle-zoom-control
Design tokens & MODO theming
The library is built on a two-layer customisation model:
- Component-scoped CSS variables (
--uxm-{component}-*) — per-instance fine-tuning set inline or via a higher CSS scope. Each component README lists its full surface. - Global design tokens (
--color-*) — declared on:rootbytokens.css, consumed as fallbacks inside every component-scoped variable. This is the MODO-configurable layer: brand-settings UIs (like MODO'sBrandSettingsPreview) edit these tokens centrally and instantly re-tint every consumer.
The canonical token catalogue lives in src/tokens/index.ts as the themeTokens array. Each entry carries:
name— display label shown in MODO's brand-settings editor (e.g."Accent Bold")cssVar— the--color-*identifier consumed by componentshex/darkHex— light/dark defaultsgroup— one ofsurfaces·text·borders·accent·highlights·categories·semanticidentity(optional) — promotes the token into the brand editor's Identity section as a top-level knob; reserved for tokens that fan out (the accent base drives the whole ramp)
tokens.css and themeTokens must stay in lockstep — npm run check:drift fails when a --color-* variable exists on one side only.
import { themeTokens, findToken } from '@viax.io/uxm/tokens';
themeTokens.forEach((t) => {
console.log(`${t.group}/${t.name} → ${t.cssVar} = ${t.hex} (dark: ${t.darkHex})`);
});
const accent = findToken('--color-accent-bold');
// ^ { name: 'Accent Bold', cssVar: '--color-accent-bold', hex: '#1E7150', … }Each component README's Design tokens (MODO-configurable) section names every token the component reads, paired with its Group / Name from themeTokens — making it straightforward to look up "if I edit X in MODO, what re-tints?"
Localisation
The library formats; you translate. @viax.io/uxm ships no i18n engine — no message catalogue, no translation runtime — because a primitives library that owns translation forces its choice of engine onto every consuming app. Localisation splits in two:
Copy is yours, and arrives as props. Every user-visible string a component can render or announce has a prop with an English default — clearLabel, closeLabel, emptyState, requiredMessage, placeholder, or a grouped labels={{ … }} object where a component owns several. Translate in your app and pass the result down. A string you cannot reach from props is a bug — open an issue.
Formatting is ours, and follows one locale. Month names, decimal separators, and byte units come from Intl and can't be expressed as a prop string, so the components that need them read a locale instead. Mount UxmLocaleProvider once at the app root:
import { UxmLocaleProvider } from '@viax.io/uxm';
<UxmLocaleProvider locale="uk-UA">
<App />
</UxmLocaleProvider>;Calendar, DateInput, EditableCell, CurrencyInput, and FileUpload pick it up automatically. Each still takes a locale prop that wins locally, so one always-USD amount can opt out. Without a provider everything falls back to en-US, exactly as before.
To let the user change the language, use LanguageSwitcher — the one control the library owns. It takes the locale list rather than fetching it, and disappears entirely (no wrapper, no disabled control) when only one locale is configured:
<LanguageSwitcher locales={supportedLocales} value={locale} onChange={setLocale} label={t('language')} />Labels that interpolate a value take a function, not a prefix — removeFile(name), uploadProgress(percent, size), countLabel(count) — because word order and pluralisation around the value are language-specific:
<FileUpload
files={files}
titleText={t('upload.title')}
labels={{
uploading: t('upload.busy'),
removeFile: (name) => t('upload.remove', { name }),
}}
/>Previews
Previews live under @viax.io/uxm/previews and are designed for host shells building theme editors. Each preview:
- Accepts a uniform
PreviewProps = { componentId, styles, variants, shell? }signature. - Reads
styles(slider/colorpicker knob values) and projects them as inline CSS variables on the component instance, exercising the production CSS path so what designers see is what consumers ship. - Optionally reads
shell(host-provided context: brand, theme,uploadAsset) to integrate with the editor's broader state — e.g.LoginPagePreviewandBrandSettingsPreviewuseshell.uploadAssetfor logo uploads.
import { ButtonPreview, type PreviewShellContext } from '@viax.io/uxm/previews';
<ButtonPreview
componentId="button-primary"
styles={{ backgroundColor: '#1E7150', borderRadius: 8, paddingX: 20, paddingY: 10 }}
variants={{ state: 'hover' }}
shell={shell satisfies PreviewShellContext}
/>;Tree-shake guarantee: no preview symbols leak into @viax.io/uxm/ui, so consumers that only import primitives never pay for preview code. ESLint enforces it (eslint.config.mjs): no-restricted-imports rejects any *-preview re-export from src/ui/index.ts or a src/ui/*/index.ts, and the import/no-restricted-paths zones pin the layer direction — ui may not import studio or the previews barrel, tokens imports nothing, and lib / hooks / helpers may not import components. After a build, grep "Preview" dist/ui/index.js still returns nothing.
Studio
@viax.io/uxm/studio ships UxmApp, the design workbench MODO serves at /uxm: sidebar, canvas, properties panel and WCAG panel over the same previews. It is decoupled from any backend through the StudioPersistence contract — pick an adapter per host:
createHttpPersistence()— reads and writes against a Hono API.createClientPersistence()— live preview plus client-side asset uploads (what theportal/dev shell uses).createReadOnlyPersistence()— a static, view-only portal.
import '@viax.io/uxm/tokens.css';
import '@viax.io/uxm/ui.css';
import '@viax.io/uxm/studio.css';
import { UxmApp, createHttpPersistence } from '@viax.io/uxm/studio';
<UxmApp persistence={createHttpPersistence('/api/uxm')} />;@viax.io/uxm/studio/generate-css exposes generateOverridesCss and the CSS sanitizers on their own, so a server can render the saved overrides into a stylesheet without loading the workbench. The studio is the only layer styled with Tailwind; studio.css bundles those utilities and the token declarations but not the atom CSS.
Architecture
┌─────────────────────────────────────────────────┐
│ @viax.io/uxm/studio (+ studio.css) │ ← the design workbench (MODO /uxm)
│ UxmApp, registry, StudioPersistence adapters │
└────────────────┬────────────────────────────────┘
│ renders
▼
┌─────────────────────────────────────────────────┐
│ @viax.io/uxm/previews │ ← host editors (MODO brand-settings)
│ themable, shell-aware preview components │
└────────────────┬────────────────────────────────┘
│ consumes
▼
┌─────────────────────────────────────────────────┐
│ @viax.io/uxm/ui (+ ui.css) │ ← application code
│ BEM-classed React components │
│ + Icon registry (ICONS, getIcon, …) │
│ built on @viax.io/uxm/hooks │
└────────────────┬────────────────────────────────┘
│ reads fallbacks
▼
┌─────────────────────────────────────────────────┐
│ @viax.io/uxm/tokens (+ tokens.css) │ ← MODO brand-settings edits this
│ themeTokens array + --color-* declarations │
└─────────────────────────────────────────────────┘The dependency direction is studio / previews → ui → tokens, enforced by the ESLint layer zones. Every layer is independently importable; every layer has its own type declarations and own CSS bundle.
Build & develop
npm install
npm run dev:modo # Vite dev server for the studio portal — fastest loop, no build needed
npm run build:modo # Vite production build of the portal
npm run build # full dist/ (see the pipeline below)
npm run typecheck # tsc --noEmit for src + portal
npm run lint # eslint .
npm run lint:fix # eslint . --fix
npm run check:drift # state-var drift, type-scale and tokens.css ↔ themeTokens parity gates
npm test # Vitest smoke suite (tests/), jsdom — `npm run test:watch` while iterating
npm run test:coverage # coverage summary for orientation only — no thresholds, not a gate
npm run dev # tsup --watch — only for local linked dev against a consumernpm run dev:modo is the fastest way to see a UI change. The portal pulls src/ui/**/*.scss through import.meta.glob, so components render with real CSS over HMR without building dist/ first.
Tests are a small Vitest smoke suite, not coverage. npm test (tests/*.test.tsx, jsdom + Testing Library + axe-core) pins the contracts nothing else can see: focus trap / Escape / outside-click on the floating layers, ARIA wiring on the pickers, the CSS sanitizers and the overrides generator, and an axe pass over a form and a dialog. It also pins the public surface: the export names of every subpath, every --uxm-* variable the stylesheets read, and that dist/ loads as ESM and CJS (snapshots in tests/__snapshots__/; a minus line in the diff is a removed export or knob, i.e. a MAJOR). See tests/README.md. Add a test when you touch one of those contracts or fix a behavioural bug; do not chase coverage on presentational atoms. For a visual change "tests pass" proves nothing — the verification is typecheck, lint, build and the portal.
CI (.github/workflows/ci.yml) runs lint, typecheck, check:drift, build and test:ci on every pull request, plus an npm audit job that fails at critical.
npm run build chains five steps, and the order is deliberate:
tsup— ESM + CJS JS withbundle: falseanddts: false(one output per source file). ItsonSuccesshook compilessrc/ui/**/*.scss→ siblingdist/ui/**/*.css, then copiesstyles.cssandtokens/index.css.build:studio-css— Tailwind CLI compilessrc/studio/studio.css.tsc -p tsconfig.build.json— a separate declaration pass. DTS is off in tsup because its single-worker DTS pass runs out of memory with 100+ unbundled entries.tsc-alias— rewrites@/*path aliases to relative paths acrossdist(JS + CJS +.d.ts).scripts/fix-cjs-requires.mjs— post-processes CJSrequire()calls.
Skipping the separate tsc pass or the alias rewrite yields a dist with either no types or unresolved @/ imports.
Output layout under dist/:
dist/
├── index.{js,cjs,d.ts}
├── ui/
│ ├── index.{js,cjs,d.ts}
│ ├── styles.css
│ └── …per-component files…
├── tokens/
│ ├── index.{js,cjs,d.ts}
│ └── index.css
├── hooks/
│ └── index.{js,cjs,d.ts}
├── previews/
│ ├── index.{js,cjs,d.ts}
│ └── …per-preview files…
└── studio/
├── index.{js,cjs,d.ts}
├── studio.css
└── persistence/generate-css.{js,cjs,d.ts}Local linked development (e.g. modo)
From the consumer project (apps/modo):
npm install file:../../uxm # adjust pathRun npm run dev here to keep dist/ fresh; the consumer's dev server picks up changes after each recompile.
To iterate on the components themselves rather than on the integration, prefer npm run dev:modo — it needs no build step at all.
Releases
Releases are fully automated — never publish by hand. Merging to master runs semantic-release in CI, which derives the version bump from the Conventional Commit types, writes CHANGELOG.md, stamps the AI-skill version markers, publishes to npm, and creates the GitHub release.
This makes commit types carry semver meaning: fix → PATCH, feat → MINOR, a BREAKING CHANGE: footer → MAJOR. Running npm version or npm publish locally would desynchronise the tags from what CI has already released.
Contributing
- Add a component: create
src/ui/{name}/with{name}.tsx,{name}.scss,index.ts. Re-export from both the folderindex.tsandsrc/ui/index.ts. Add the compiled stylesheet tosrc/ui/styles.cssas@import "./{name}/{name}.css";in cascade order — the aggregator is hand-written, and a component whose@importis missing ships with no CSS. Add aREADME.mdmirroringbutton/README.md(simple) ordata-table/README.md(complex). - Update the AI skill in the same change:
skills/viax-uxm/needs a catalog row, a cheatsheet row, and a bullet under### UnreleasedinSKILL.md. Leave the version and component-count markers alone — CI stamps those at release viascripts/stamp-skill-version.mjs. - Add a preview: create
{name}-preview.tsxnext to the component — previews live beside their component, not insrc/previews/. UsePreviewPropsand project knob values as inline CSS vars so production CSS rules paint them. Never re-export a preview from asrc/ui/barrel (see the tree-shake guarantee above). - Add a token: add an entry to
themeTokensinsrc/tokens/index.tsand declare the--color-*variable insrc/tokens/index.css—npm run check:driftfails if only one side changes. Reference it from component SCSS viavar(--uxm-foo-bar, var(--color-new-token))— never hardcode a colour, spacing, or radius. - Commits follow conventional-commit format (
commitizen+commitlintenforced viahusky);npm run commitwalks you through it. Commit types drive the released version — see Releases. - Run
npm run lint && npm run typecheck && npm run check:drift && npm test && npm run buildbefore opening a pull request — the same gates CI runs.
License
MIT © viax.io
