ranui
Version:
A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.
459 lines (328 loc) • 15.5 kB
Markdown
# ranui
An experimental UI component library based on Web Components. Components use Shadow DOM encapsulation, CSS Token theming, and SSR / Declarative Shadow DOM support.
---
<a href="https://github.com/chaxus/ran"><img src="https://img.shields.io/github/actions/workflow/status/chaxus/ran/ci.yml" alt="Build Status"></a>
<a href="https://github.com/chaxus/ran"><img src="https://img.shields.io/npm/v/ranui.svg" alt="npm-v"></a>
<a href="https://github.com/chaxus/ran"><img src="https://img.shields.io/npm/dt/ranui.svg" alt="npm-d"></a>
<a href="https://github.com/chaxus/ran"><img src="https://img.badgesize.io/https:/unpkg.com/ranui/dist/umd/shadowless/shadowless.umd.cjs?label=brotli&compression=brotli" alt="brotli"></a>
<a href="https://github.com/chaxus/ran"><img src="https://img.shields.io/badge/module%20formats-umd%2C%20esm-green.svg" alt="module formats: umd, esm"></a>
[中文](./README.zh-CN.md) | **English**
## Important Notice
This is an **experimental UI library** in early development. It is usable, but primarily intended for learning and experimentation.
Key points:
- **Early development**: features are still being developed and refined.
- **Experimental**: APIs may change frequently.
- **Learning-oriented**: mainly intended for learning Web Components and UI development.
## Features
1. **Cross-framework compatibility:** works with React, Vue, Preact, SolidJS, Svelte, and any JavaScript project that follows W3C standards.
2. **Native experience:** use custom elements such as `<r-button>` and `<r-modal>` like native HTML elements.
3. **Modular design:** supports both full imports and per-component imports for better maintainability and bundle control.
4. **Shadow DOM encapsulation:** component internals are isolated by default, while CSS Tokens, `::part()`, and the `sheet` attribute provide controlled styling hooks.
5. **TypeScript support:** built with TypeScript and type definitions.
6. **SSR friendly:** supports server rendering through `defineSSR`, `renderToString`, and Declarative Shadow DOM.
7. **Accessible:** ARIA roles/states, full keyboard navigation, form-associated inputs (`<r-checkbox>`/`<r-input>`/`<r-select>` participate in native `FormData`), live-region toasts, and `prefers-reduced-motion` support.
## Installation
Using npm:
```console
npm install ranui --save
```
## Documentation and Examples
[See components and use examples](https://chaxus.github.io/ran/src/ranui/)
### Components & API reference
Every element's attributes, properties, **events (with `detail` shapes)**, slots, and `::part()` names are generated from source — no need to grep the exports:
- Per-element API: [docs/COMPONENTS.md](./docs/COMPONENTS.md)
- Design standard (color/spacing/typography/motion/a11y): [docs/DESIGN.md](./docs/DESIGN.md)
Regenerate after changing a component's API with:
```bash
pnpm doc:api
```
### AI / Claude Code skill
A ready-to-use skill lets AI assistants (Claude Code) read and use ranui without
spelunking the source. It's published from the `ran` plugin marketplace:
```bash
/plugin marketplace add chaxus/ran
/plugin install ranui@ran
```
Once installed, Claude uses it automatically when you work with ranui (or invoke it
explicitly as `/ranui:ranui`). The skill covers the import map, element inventory, the
builder/reactivity API, accessibility, and usage examples, and points to the API
reference shipped in the package ([docs/COMPONENTS.md](./docs/COMPONENTS.md)).
### Styling Documentation
The styling system is unified around CSS Tokens and `::part()`.
- Style override guide: [docs/style-override.md](./docs/style-override.md)
- Complete Token/Part list, generated automatically: [docs/style-tokens-parts.md](./docs/style-tokens-parts.md)
- Public styling API for consumers, generated automatically: [docs/style-tokens-public.md](./docs/style-tokens-public.md)
- Public token filter config: [docs/style-token-filter.json](./docs/style-token-filter.json)
Refresh the styling docs with:
```bash
pnpm doc:style
```
### Theming
ranui ships a single token system based on the [Geist design system](https://vercel.com/geist) — Vercel's open-source design language, where color is a **state ladder** (each scale runs 100→1000, one job per step: background → hover → border → solid fill → text). ranui adopts that ladder plus **Geist Sans / Geist Mono**, so dark mode just redefines the base scale and every semantic token flips automatically. Three modes — `light`, `dark`, `system` — and no theme packs. Switch the mode or override any token at runtime (SSR-safe):
```ts
import { initTheme, setTheme, setThemeToken, setThemeTokens } from 'ranui/theme';
import 'ranui/style';
initTheme(); // restore the persisted choice on load
setTheme('system'); // 'light' | 'dark' | 'system'
setThemeToken('--ran-color-primary', '#6c47ff');
setThemeTokens({ '--ran-radius-md': '10px' });
```
The `ranui/theme` entry ships only the theming engine — no custom elements are
registered, so it stays out of your bundle if you just want tokens/dark mode.
The same APIs are also re-exported from the `ranui` barrel.
Dark mode redefines only the base color scale; semantic tokens (`--ran-color-*`) reference it and flip automatically. See [docs/THEME_STYLE_SYSTEM_DESIGN.md](./docs/THEME_STYLE_SYSTEM_DESIGN.md) and [docs/DESIGN.md](./docs/DESIGN.md).
### Internationalization
A framework-agnostic i18n engine ships as its own `ranui/i18n` entry — like
`ranui/theme`, it registers no custom elements:
```ts
import { createI18n, useI18n } from 'ranui/i18n';
createI18n({
// each locale is a flat dictionary — keys are used verbatim (no nesting)
messages: { en: { 'hero.title': 'Hi {name}' }, zh: { 'hero.title': '你好 {name}' } },
fallbackLocale: 'en',
persist: true, // remember the choice in localStorage
detectNavigator: true, // pick the initial locale from the browser
});
useI18n()!.t('hero.title', { name: 'Ada' }); // → "Hi Ada"
useI18n()!.setLocale('zh'); // persists and notifies subscribers
```
`t()` falls back to the fallback locale, then to the key itself; `{param}`
placeholders are interpolated. The core is SSR-safe.
## Imports
Use per-component imports to reduce bundle size:
```js
import 'ranui/button';
```
Non-component subpaths ship the utilities on their own, so you can pull in just
the engine you need without registering every element:
```js
import { initTheme } from 'ranui/theme'; // theming only
import { createI18n } from 'ranui/i18n'; // i18n only
```
If styles are missing, import the stylesheet manually:
```js
import 'ranui/style';
```
If type resolution fails, import one of the type entry points manually:
```ts
import 'ranui/typings';
// or
import 'ranui/dist/index.d.ts';
// or
import 'ranui/type';
// or
import 'ranui/dist/typings';
```
Only one working type entry is needed.
Full import is also supported:
```ts
import 'ranui';
```
ES module:
```js
import 'ranui';
```
or:
```js
import 'ranui/button';
```
UMD, IIFE, CJS:
```html
<script src="./ranui/dist/umd/index.umd.cjs"></script>
```
### Without a bundler (static pages / CDN)
Pick the distribution that matches how many components the page uses:
| Scenario | Best option | Why |
| ------------------------------ | ---------------------------------------------- | -------------------------------------------------------------------- |
| 1–2 components, one script tag | Per-component IIFE: `dist/iife/<name>.iife.js` | Self-contained, no module syntax needed |
| Several components | Per-component ES modules: `dist/<name>.js` | Shared runtime chunks are deduplicated by the browser's module graph |
| Everything | Full bundle: `dist/index.iife.js` | One file, every component registered |
| Project with a bundler | npm imports: `import 'ranui/<name>'` | Tree-shaking and a single shared runtime |
Per-component IIFE — one tag, no build step:
```html
<script src="https://cdn.jsdelivr.net/npm/ranui/dist/iife/select.iife.js" defer></script>
```
Each IIFE inlines its internal dependencies (e.g. `select` includes `icon`); element registration is guarded, so loading several files that share dependencies is safe — but each file carries its own copy of the shared runtime. When a page needs several components, prefer the ES modules instead, which deduplicate it:
```html
<script type="module">
import 'https://cdn.jsdelivr.net/npm/ranui/dist/button.js';
import 'https://cdn.jsdelivr.net/npm/ranui/dist/select.js';
</script>
```
## Usage
RanUI components are Web Components, so they can be used without framework-specific wrappers.
In most cases, use them like native HTML elements.
Examples:
- html
- js
- jsx
- vue
- tsx
### html
```html
<script src="./ranui/dist/umd/index.umd.cjs"></script>
<body>
<r-button>Button</r-button>
</body>
```
### js
```js
import 'ranui';
const Button = document.createElement('r-button');
Button.textContent = 'this is button text';
document.body.appendChild(Button);
```
### jsx
```jsx
import 'ranui';
const App = () => {
return (
<>
<r-button>Button</r-button>
</>
);
};
```
### vue
```vue
<template>
<r-button></r-button>
</template>
<script>
import 'ranui';
</script>
```
### tsx
```tsx
import 'ranui/button';
const Button = () => {
return (
<div>
<r-button type="primary">button</r-button>
</div>
);
};
```
### Message Position and Container
`window.message` supports custom top offset, z-index, and mount container:
```ts
import 'ranui/message';
const customRoot = document.getElementById('custom-message-root');
window.message?.success({
content: 'Saved',
duration: 2000,
top: 24,
zIndex: 3000,
getContainer: () => customRoot,
});
```
`top` supports `number | string`; `24` becomes `24px`, while `'2rem'` keeps its unit.
`zIndex` supports `number | string`.
`getContainer` must return an `HTMLElement`; when omitted, messages mount to `document.body`.
### Reactive Primitives
`signal`, `createEffect`, `computed`, `batch`, `untrack`, and an ownership layer (`createRoot` / `onCleanup` / `getOwner` / `runWithOwner`) ship alongside the DOM builder for building reactive page sections without a framework. The design mirrors SwiftUI's `@Observable` with Solid.js-style guarantees: effects auto-clean stale subscriptions before each re-run; `batch()` coalesces writes into one flush; `computed` is **lazy + value-memoized** (an unread memo never computes, and it wakes dependents only when its value actually changes); and every effect/memo/binding is owned by its scope, so disposing a `createRoot` tears down everything it spawned in one call — the teardown unit for a page/route. `ElementBuilder` chainables (`text`/`attr`/`class`/…) also accept a signal getter for auto-updating bindings. Full guide: [`docs/BUILDER.md`](docs/BUILDER.md).
```ts
import { signal, createEffect, computed, batch, EventManager, Div, ButtonBuilder } from 'ranui/builder';
function initCounter(container: HTMLElement) {
const [count, setCount] = signal(0);
const [step, setStep] = signal(1);
const doubled = computed(() => count() * 2);
const scope = new EventManager();
const label = Div().build();
const view = Div()
.children(
label,
ButtonBuilder()
.text('+')
.listen(scope, 'click', () => setCount((n) => n + step())),
ButtonBuilder()
.text('reset')
.listen(
scope,
'click',
() =>
batch(() => {
setCount(0);
setStep(1);
}), // two writes → one effect flush
),
)
.build();
const dispose = createEffect(() => {
label.textContent = `${count()} (×2 = ${doubled()})`;
});
container.appendChild(view);
return () => {
dispose();
scope.abort();
}; // teardown
}
```
See [Utility Documentation](./utils/README.md) for the full API.
### Routing
RanUI includes client-side routing via declarative components and a JavaScript API.
**Declarative components:**
```html
<r-router>
<nav>
<r-link href="/">Home</r-link>
<r-link href="/about">About</r-link>
</nav>
<r-route path="/" exact><h2>Home</h2></r-route>
<r-route path="/about"><h2>About</h2></r-route>
<r-route path="/users/:id"><h2>User detail</h2></r-route>
</r-router>
```
**JavaScript API with navigation guard:**
```ts
import { createRouter } from 'ranui';
const router = createRouter({
mode: 'history',
routes: [
{ path: '/', exact: true, meta: { title: 'Home' } },
{ path: '/users/:id', meta: { requiresAuth: true } },
],
viewTransition: 'spa', // 'spa' | 'mpa' | 'both'
});
router.beforeEach((to, from, next) => {
if (to.meta?.requiresAuth && !isLoggedIn()) next('/login');
else next();
});
router.push('/users/42');
```
For pure MPA sites (no JS router needed), use `enableMpaViewTransitions()` to inject `@view-transition { navigation: auto }`. Shared-element morph animations are supported via the standard `view-transition-name` CSS property.
```ts
import { enableMpaViewTransitions } from 'ranui';
enableMpaViewTransitions();
```
See the [Router documentation](https://chaxus.github.io/ran/src/ranui/router/) for the full API including guards, `onPageSwap`/`onPageReveal`, and per-element transition names.
### SSR & Builder
For SSR or declarative UI construction, RanUI internally uses `builder`, the SSR registry, and Declarative Shadow DOM. Components reuse existing Shadow Roots through `ensureShadowRoot` and keep initialization idempotent through `ensureShadowElement`.
Source-level SSR rendering example:
```ts
import { Button } from '@/components/button';
import { renderToString } from '@/utils/ssr';
const button = new Button();
button.setAttribute('effect', 'true');
// Outputs an HTML string containing Declarative Shadow DOM.
const html = renderToString(button);
```
See [Utility Documentation](./utils/README.md) for details.
## Component Development Conventions
When adding or maintaining components, follow the package conventions:
- Extend `RanElement`; do not directly extend the browser `HTMLElement`.
- Use `ensureShadowRoot` to create or reuse Shadow Roots; do not call `attachShadow` directly.
- Use `ensureShadowElement` to build Shadow DOM subtrees idempotently.
- Include `sheet` in `observedAttributes` and sync component-level style overrides through `syncSheetAttribute`.
- Guard `attributeChangedCallback` with `if (old === next) return;`.
- Register components with `defineSSR('r-name', Component)`, not direct `customElements.define`.
- Add both type exports and side-effect imports in `index.ts`; also add standalone entries in `vite.config.ts` and `package.json`.
- Use `EventManager` from `@/utils/builder` for lifecycle-bound listeners in `connectedCallback`; call `manager.abort()` in `disconnectedCallback` instead of tracking individual `removeEventListener` calls.
## Contributing
Contributions from learners and developers are welcome. This is an experimental project, so please expect active iteration.
## Contributors
<a href="https://github.com/chaxus/ran/graphs/contributors">
<img src="https://contrib.rocks/image?repo=chaxus/ran" />
</a>
## Meta
[LICENSE (MIT)](/LICENSE)