Lightweight Web Component framework — one defineComponent(), no class syntax, no framework lock-in.
Lit powers rendering internally but is never exposed. Developers interact exclusively through the Wely API. Output is native custom elements — fully portable, they run in plain HTML, React, Vue, Angular, Svelte, anywhere.
Building frontend components today typically means choosing a heavy framework and wiring up a dozen tools. Wely replaces that with a single unified toolkit.
| Step | What you do | What Wely handles |
|---|---|---|
| Configure | wely init → wely.config.ts, package.json (wely dev / wely build, ^welyjs), src/bundle.ts, components index | Centralized config via ctx.config |
| Create | wely create w-card --props title:String | Scaffolds component file and barrel index |
| Develop | wely dev | Hot-reloading playground |
| Style | Use Tailwind classes in templates | Compiled and injected into Shadow DOM |
| Fetch | createClient({ baseURL }) | HTTP client with interceptors, timeout |
| State | ctx.resource() / ctx.use(store) | Async resources + shared stores |
| Test | wely test | Vitest + jsdom (add devDeps); bundled default config when no local vitest.config / vite.config — avoids resolving a parent folder’s Vite project |
| Build | wely build | ES + UMD bundles |
| Export | wely export ../other-project/lib | Copies output to any folder |
| Document | wely docs | Generates COMPONENTS.md |
One CLI, one config file — from scaffolding to production.
Wely produces minimal bundles. Runtime includes Lit, our API (defineComponent, store, resource, fetch), and Tailwind CSS. All sizes are minified + gzipped.
| Build | Size (min+gzip) |
|---|---|
Runtime only (wely.es.js) | 13 KB |
| 1 component (w-button) | 13.3 KB |
| 2 components (+ w-counter) | 13.6 KB |
| 3 components (+ w-counter-card) | 14 KB |
| 5 components (+ w-pokemon-grid, w-user-list) | 15 KB |
Per-component overhead: ~0.4–0.5 KB for simple components.
Pay for what you use — Wely bundles only what you import. Add one component → ~13 KB. Add five → ~15 KB. No framework runtime at the consumer; output is native Web Components. Tree-shaking keeps the bundle minimal: unused components never land in the final file.
Minimal (new project): Only wely.config.ts + welyjs — no vite.config, no extra deps.
mkdir my-app && cd my-app
wely init # package.json + wely.config + bundle entry + components/
npm install
wely create w-hello --props msg:String
wely build # → dist/wely.bundle.*.js
wely dev # playground (or: npm run dev — same scripts after init)
Full repo (Wely development):
npm install
npm run dev # playground at localhost:5173
npm run build # library → dist/wely.es.js + dist/wely.umd.js
npm run test
npm run test:run
Typical loop for an app that lives in its own folder: scaffold → add or edit components → use the playground for quick feedback → build artifacts for production or static hosting.
1 · Bootstrap
wely init → npm install
Creates wely.config.ts, package.json (wely dev / wely build, welyjs range from the CLI), src/bundle.ts, src/wely-components/index.ts
2 · Components
wely create <tag> or edit *.ts under your components folder
Import each file from index.ts so defineComponent() runs and registers tags
3 · Try in the playground
wely dev (no local vite.config)
Gallery lists all registered components · Preview lab: HTML editor (highlighted), live preview, optional session restore · Hash routes #/gallery, #/preview?tag=…
4 · Build & ship
wely build → dist/ bundle (and library modes when a vite.config exists)
wely export <path> copies artifacts · wely page prepares docs/ for GitHub Pages
Loop back from step 3 anytime: change components, save, HMR updates the playground. Step 4 is when you need a distributable bundle or a deployable folder.
┌──────────────┐ ┌──────────────┐ ┌─────────────────────────┐
│ wely init │ ──► │ npm install │ ──► │ Edit / wely create tags │
└──────────────┘ └──────────────┘ └───────────┬─────────────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────────────┐
│ wely export │ ◄── │ wely build │ ◄── │ wely dev → playground │
│ wely page │ │ → dist/ │ │ Gallery · Preview lab │
└──────────────┘ └──────────────┘ └─────────────────────────┘
wely test)Install vitest and jsdom as devDependencies (wely init adds them and a test script). wely test runs Vitest in watch mode; wely test --run runs once (CI).
If the project has no vitest.config.* and no vite.config.*, the CLI passes the published welyjs/vitest-consumer config so Vitest does not accidentally load a vite.config from a parent directory. Default globs: src/**/*.test.ts, src/**/*.spec.ts; environment jsdom; passWithNoTests: true. Add your own config when you need aliases, coverage, or different roots.
wely dev starts a Vite dev server with hot reload. On first run (when no vite.config exists), the CLI uses the bundled vite.dev.config.ts: the published index.html shell and a virtual entry that imports your wely.config, dev CSS, your components index.ts (resolved as a real module so the registry fills), then mountApp(). If you already have a vite.config, plain vite runs instead — use the same wely dev / npm run dev scripts from wely init to get the playground path.
The UI is a single page with client-side routing via location.hash — no extra HTML files. Default route is #/home.
| Route | Purpose |
|---|---|
#/home | Intro, shortcuts to other views |
#/docs | Copy-paste ES module & UMD snippets, CLI cheat sheet |
#/gallery | All components in searchable cards with live props |
#/preview · #/preview?tag=w-button | Preview lab: HTML editor with syntax highlighting (CodeMirror), Live preview (debounced) without overwriting your markup, one sandbox for multiple tags, props for the first registered element in document order, sessionStorage for snippet / filter. Apply now syncs the URL. |
Captured from the Wely repo playground (npm run dev). Regenerate with node scripts/capture-playground-screenshots.mjs.
Below: Preview with w-button selected. Two columns — tag filter + list; main pane has title, Props (edits push into the markup field), dashed stage, then markup textarea with Live preview so typing updates the preview after a short delay. You can wrap tags in extra HTML; the first matching registered custom element is used. Apply now applies immediately and syncs the hash. Narrow viewports may wrap the top nav.
Wely repo (full checkout): index.html + src/playground/* + src/styles/tailwind.css — npm run dev uses the same entry as below.
Consumer app (only wely init files, no vite.config): the CLI ships the shell HTML and a virtual entry that imports your wely.config.ts, generated dev CSS, welyjs/playground/app, and <componentsDir>/index.ts — you do not copy src/playground/ into your repo.
| Artifact | Purpose |
|---|---|
index.html (from welyjs package) | Shell with #app; inline playground chrome styles |
virtual:wely-playground | Entry: config → dev CSS → mountApp() after dynamic import of components |
src/playground/app.ts | Navigation, routes, views — resolved via welyjs/playground/app alias |
src/wely-components/index.ts (your project) | Side-effect imports that register each component |
Auto-rendering: getAllComponents() lists every registered component. New components from wely create show up immediately (HMR).
Interactive props: In the gallery (and preview lab), props get live inputs; attributes update the element and trigger re-renders.
Consumer projects without vite.config: vite.dev.config.ts serves the same HTML shell from the published index.html and a virtual entry that imports welyjs/playground/app after your config and components — same UX as the repo. The components entry is resolved to <componentsDir>/index.ts so Vite always loads your registration side effects.
Every component is a plain object passed to defineComponent():
import { defineComponent, html } from 'welyjs'
defineComponent({
tag: 'w-counter',
props: { start: Number },
state() { return { count: 0 } },
setup(ctx) { ctx.state.count = ctx.props.start ?? 0 },
actions: {
increment(ctx) { ctx.state.count++ },
decrement(ctx) { ctx.state.count-- },
reset(ctx) { ctx.state.count = ctx.props.start ?? 0 },
},
render(ctx) {
return html`
<button @click=${ctx.actions.decrement}>-</button>
<span>${ctx.state.count}</span>
<button @click=${ctx.actions.increment}>+</button>
<button @click=${ctx.actions.reset}>Reset</button>
`
},
})
Use it: <w-counter start="5"></w-counter>
Wely components are native Custom Elements — nest them by using the tag name:
render(ctx) {
return html`
<div class="border rounded-lg p-4">
<h3>${ctx.props.title}</h3>
<w-counter start=${ctx.props.start ?? 0}></w-counter>
<w-button label="Action" @w-click=${ctx.actions.onClick}></w-button>
</div>
`
}
ctx.emit('event-name', payload))createStore() + ctx.use(store))Fetching data with ctx.resource(). Data from PokéAPI.
Registers a native custom element. Fields: tag, props, devInfo, styles, state(), actions, setup(ctx), render(ctx), connected(ctx), disconnected(ctx). Actions receive (ctx, event?) — with @input, @click, etc., event.target gives the element.
| Property | Description |
|---|---|
ctx.el | Host HTMLElement |
ctx.props | Readonly attribute-synced properties |
ctx.state | Auto-reactive state (mutations trigger re-render) |
ctx.actions | Bound action map — handlers receive (ctx, event); event.target for the element |
ctx.update() | Manually request re-render |
ctx.emit(event, payload?) | Dispatch CustomEvent |
ctx.resource(fetcher, opts?) | Async resource bound to lifecycle |
ctx.use(store) | Subscribe to shared store |
ctx.config | Read-only config from wely.config.ts |
Zero-dependency HTTP client (native fetch). Axios-like: get, post, put, patch, delete, onRequest, onResponse, onError. Interceptors, timeout, query params, JSON.
Async data primitive. Use via ctx.resource(). Tracks data, loading, error. Methods: fetch(), refetch(), abort(), mutate(), reset().
Shared reactive state. state: () => ({...}), actions: { name(state, ...args) {...} }. Use ctx.use(store) to subscribe.
Define in wely.config.ts with defineConfig(). Read with getConfig(), useConfig(key), or ctx.config. Supports import.meta.env.VITE_*.
html, css, nothing
When enabled (default), each component gets data-wely-version and data-wely-mounted attributes — visible in browser DevTools. Version comes from defineConfig({ version: '1.0.0' }) or per-component override.
// Default: attributes added
defineComponent({ tag: 'w-card', render: () => html`...` })
// Disable
defineComponent({ tag: 'w-secret', devInfo: false, render: () => html`...` })
// Override version per component
defineComponent({ tag: 'w-card', devInfo: { version: '2.0.0' }, render: () => html`...` })
Set version in wely.config.ts for global devInfo version.
Override the default src/wely-components via package.json:
{
"wely": { "componentsDir": "src/components" }
}
Used by create, sync, list, docs, build, dev.
Override the default dist output directory via package.json:
{
"wely": { "componentsDir": "src/components", "outDir": "build" }
}
Used by build, export, page. Both the library config and CLI respect this value.
Runs in the current directory. Use wely --help, wely -v, and wely help <command> for subcommands. New projects: wely init → npm install → wely create → wely build. wely init writes package.json with wely dev / wely build and a welyjs semver range matching the installed CLI.
# Setup
wely init # wely.config.ts + package.json + bundle + components index
npm install
# Components
wely create w-card --props title:String
wely create w-user --props name:String,age:Number --actions refresh,delete
wely sync
wely list
wely docs
wely docs --out docs/api.md
# Build (minimal project: bundle by default; full repo: library)
wely build
wely build --bundle
wely build --chunks # vendor, runtime, components split
wely build --all
wely build --export ../app/public/vendor/wely
# Deploy
wely export ../other-project/lib
wely export ./out --no-build
wely export ../lib --clean
# Dev & test
wely dev
wely test # watch — needs vitest + jsdom (wely init adds them)
wely test --run # single run (CI)
# GitHub Pages
wely page # → docs/
| Command | Output | Use case |
|---|---|---|
wely build (no vite.config) | wely.bundle.*.js | Consumer project — runtime + your own components |
wely build --chunks | wely.chunked.es.js + chunks/*.js | Vendor, runtime, components split — cache-friendly |
wely build (with vite.config) | wely.es.js, wely.umd.js | Library — runtime only (packaging) |
Bundle: drop-in script. Library: import { defineComponent, html } from 'welyjs'.
Chunked build: wely build --chunks splits output into vendor (Lit), runtime (Wely API), and components. Use <script type="module" src="wely.chunked.es.js"></script>. Copy the entire dist/ folder (including chunks/) when deploying.
Size optimization: Default uses esbuild minify. For smaller bundles, use a custom vite.config with minify: 'terser' and terserOptions: { compress: { drop_console: true } } — terser yields ~5–15% smaller output.
Tailwind CSS v4 is integrated:
tailwind.css normallystyles: css`:host { ... }`Minimal setup: wely init + wely build — CLI creates tailwind.css with correct @source on first run. Bundle consumers: no config — Tailwind baked in.
Every mounted Wely component exposes its context on the DOM element as $wely. This enables direct access from DevTools, tests, or automation tools (e.g. MCP-based agents).
const el = document.querySelector('w-counter')
el.$wely.state.count // read state
el.$wely.state.count = 10 // write state (triggers re-render)
el.$wely.actions.increment() // call an action
el.$wely.props.start // read props
el.$wely.emit('my-event', { detail: 42 }) // dispatch event
window.welyInstalled automatically when the runtime loads:
wely.get('w-counter') // first matching element's ctx
wely.getAll('w-counter') // all matching elements' ctx array
wely.list() // all registered tag names
| Method | Returns | Description |
|---|---|---|
wely.get(selector) | ComponentContext | undefined | Context of the first element matching the tag or CSS selector |
wely.getAll(selector) | ComponentContext[] | Contexts of all matching elements |
wely.list() | string[] | All registered component tag names |
TypeScript: $wely is typed on HTMLElement globally. The WelyBridge interface is exported for window.wely typing.
MCP / automation: Because window.wely is plain JS, any browser automation tool (Playwright, Puppeteer, Cursor browser MCP) can call window.wely.get('w-counter').state via evaluate() to read or mutate component state programmatically.
| Browser | Minimum |
|---|---|
| Chrome | 73+ |
| Edge | 79+ |
| Firefox | 101+ |
| Safari | 16.4+ |
Custom Elements v1, Shadow DOM, adoptedStyleSheets, Proxy
Wely components are fully portable — build once, use anywhere. They are standard Web Components; no framework runtime at the consumer side.
| Aspect | Behavior |
|---|---|
| Output | Custom Elements v1, Shadow DOM — no framework-specific bundle |
| Consumer runtime | Zero Wely runtime — components are plain DOM elements |
| Drop-in | Plain HTML, React, Vue, Angular, Svelte, Astro, Eleventy, any DOM environment |
| Formats | ES module + UMD — bundlers or classic <script> |
| Deployment | wely export <path> copies output to any project |
Same component, same API — works in all environments without modification.
defineComponent() call, no classesactions pattern separates logic from templates| Layer | Tool |
|---|---|
| Language | TypeScript |
| Rendering | Lit (internal) |
| Styling | Tailwind CSS v4 |
| Dev / Build | Vite |
| Testing | Vitest + jsdom |
| Output | ES module + UMD |
src/
runtime/ defineComponent, config, registry, fetch, resource, store
components/ w-counter, w-button, w-counter-card, w-pokemon-grid, w-user-list
styles/ tailwind.css
playground/ main.ts, app.ts, home, docs, gallery, preview-lab
index.html Playground shell
wely.config.ts App config
vite.config.ts Vite + Vitest
page/ GitHub Pages landing (this page)