Wely

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.

Why Wely?

Building frontend components today typically means choosing a heavy framework and wiring up a dozen tools. Wely replaces that with a single unified toolkit.

The problem

How Wely solves it

StepWhat you doWhat Wely handles
Configurewely initwely.config.ts, package.json (wely dev / wely build, ^welyjs), src/bundle.ts, components indexCentralized config via ctx.config
Createwely create w-card --props title:StringScaffolds component file and barrel index
Developwely devHot-reloading playground
StyleUse Tailwind classes in templatesCompiled and injected into Shadow DOM
FetchcreateClient({ baseURL })HTTP client with interceptors, timeout
Statectx.resource() / ctx.use(store)Async resources + shared stores
Testwely testVitest + jsdom (add devDeps); bundled default config when no local vitest.config / vite.config — avoids resolving a parent folder’s Vite project
Buildwely buildES + UMD bundles
Exportwely export ../other-project/libCopies output to any folder
Documentwely docsGenerates COMPONENTS.md

One CLI, one config file — from scaffolding to production.

Bundle Size

Wely produces minimal bundles. Runtime includes Lit, our API (defineComponent, store, resource, fetch), and Tailwind CSS. All sizes are minified + gzipped.

BuildSize (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.

Quick Start

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

Developer workflow

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 initnpm 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 builddist/ 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.

At a glance (flow)

Unit tests (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.

Dev mode & playground

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.

Views (hash routes)

The UI is a single page with client-side routing via location.hash — no extra HTML files. Default route is #/home.

RoutePurpose
#/homeIntro, shortcuts to other views
#/docsCopy-paste ES module & UMD snippets, CLI cheat sheet
#/galleryAll components in searchable cards with live props
#/preview · #/preview?tag=w-buttonPreview 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.

Screenshots

Captured from the Wely repo playground (npm run dev). Regenerate with node scripts/capture-playground-screenshots.mjs.

Playground home: intro and buttons to browse components, preview lab, and integration docs
Home — entry screen with shortcuts
Playground docs tab: ES module example, UMD script example, CLI table
Docs — integration snippets and CLI reference
Playground components gallery: searchable list of component cards with props
Components — full gallery with search and collapse

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 playground Preview: left sidebar with Filter tags field and tag buttons; main area shows w-button title, PROPS section with label variant disabled, dashed preview box, and HTML snippet textarea below
Preview lab — live markup toggle, props sync, full-page capture.

Where the playground lives

Wely repo (full checkout): index.html + src/playground/* + src/styles/tailwind.cssnpm 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.

ArtifactPurpose
index.html (from welyjs package)Shell with #app; inline playground chrome styles
virtual:wely-playgroundEntry: config → dev CSS → mountApp() after dynamic import of components
src/playground/app.tsNavigation, 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.

Defining a Component

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>

Component Composition

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>
  `
}

Live Demo — PokéAPI

Fetching data with ctx.resource(). Data from PokéAPI.

API Surface

defineComponent(def)

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.

The ctx object

PropertyDescription
ctx.elHost HTMLElement
ctx.propsReadonly attribute-synced properties
ctx.stateAuto-reactive state (mutations trigger re-render)
ctx.actionsBound 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.configRead-only config from wely.config.ts

createClient(config?)

Zero-dependency HTTP client (native fetch). Axios-like: get, post, put, patch, delete, onRequest, onResponse, onError. Interceptors, timeout, query params, JSON.

createResource(fetcher, options?)

Async data primitive. Use via ctx.resource(). Tracks data, loading, error. Methods: fetch(), refetch(), abort(), mutate(), reset().

createStore(def)

Shared reactive state. state: () => ({...}), actions: { name(state, ...args) {...} }. Use ctx.use(store) to subscribe.

Configuration

Define in wely.config.ts with defineConfig(). Read with getConfig(), useConfig(key), or ctx.config. Supports import.meta.env.VITE_*.

Re-exported from Lit

html, css, nothing

devInfo — Dev tools attributes

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.

componentsDir — Components folder

Override the default src/wely-components via package.json:

{
  "wely": { "componentsDir": "src/components" }
}

Used by create, sync, list, docs, build, dev.

outDir — Build output folder

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.

CLI

Runs in the current directory. Use wely --help, wely -v, and wely help <command> for subcommands. New projects: wely initnpm installwely createwely 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/

Build Output

CommandOutputUse case
wely build (no vite.config)wely.bundle.*.jsConsumer project — runtime + your own components
wely build --chunkswely.chunked.es.js + chunks/*.jsVendor, runtime, components split — cache-friendly
wely build (with vite.config)wely.es.js, wely.umd.jsLibrary — 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.

Styling

Tailwind CSS v4 is integrated:

Minimal setup: wely init + wely build — CLI creates tailwind.css with correct @source on first run. Bundle consumers: no config — Tailwind baked in.

Browser Access

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).

Element-level access

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

Global helper — window.wely

Installed 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
MethodReturnsDescription
wely.get(selector)ComponentContext | undefinedContext 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 Support

BrowserMinimum
Chrome73+
Edge79+
Firefox101+
Safari16.4+

Custom Elements v1, Shadow DOM, adoptedStyleSheets, Proxy

Portability

Wely components are fully portable — build once, use anywhere. They are standard Web Components; no framework runtime at the consumer side.

AspectBehavior
OutputCustom Elements v1, Shadow DOM — no framework-specific bundle
Consumer runtimeZero Wely runtime — components are plain DOM elements
Drop-inPlain HTML, React, Vue, Angular, Svelte, Astro, Eleventy, any DOM environment
FormatsES module + UMD — bundlers or classic <script>
Deploymentwely export <path> copies output to any project

Same component, same API — works in all environments without modification.

Design Principles

Tech Stack

LayerTool
LanguageTypeScript
RenderingLit (internal)
StylingTailwind CSS v4
Dev / BuildVite
TestingVitest + jsdom
OutputES module + UMD

Project Structure

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)