yq-sanyi is a web component framework with no runtime dependencies. You define a component once, and after that it is a native HTML element you can use anywhere.
yq.define('yq-counter', {
template: '<button yq-on:click="inc">{{ count }}</button>',
style: 'button { font-size: 18px; padding: 8px 18px; }',
script: function () {
return {
state: { count: 0 },
inc: function (state) {
state.count = state.count + 1
}
}
}
})
Then, in plain HTML:
<yq-counter></yq-counter>
The tag mounts itself, renders, updates and cleans up. The template is HTML, the style is CSS, the script is JS — nothing custom to learn. No JSX, no virtual DOM, no compiler, and whoever drops the component on a page doesn't need a build step either.
~12.6 kB core bundle, gzipped
0 runtime dependencies
298 / 298 tests passing
Apache-2.0
Why another component framework?
Fair question, there are plenty.
But a lot of pages don't need a framework, they need one widget. If you pull React into a landing page for a single dropdown, you also bring its compiler, its runtime and a dependency tree. If you stay native with Web Components instead, you end up writing the templating, style isolation, reactivity and lifecycle wiring yourself, every time.
yq-sanyi is for that middle ground. It removes the boilerplate, and the result is still a plain custom element — so it works in any page, any template engine, any CMS, next to whatever else is already on the page.
Quick start
git clone https://github.com/YQteam-hq/yq-sanyi.git
cd yq-sanyi
npm install
npm run build
Then write a plain HTML file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>yq-sanyi quick start</title>
</head>
<body>
<yq-counter></yq-counter>
<script src="./packages/core/dist/core.global.js"></script>
<script>
yq.define('yq-counter', {
template: '<button yq-on:click="inc">{{ count }}</button>',
script: function () {
return {
state: { count: 0 },
inc: function (state) { state.count = state.count + 1 }
}
}
})
</script>
</body>
</html>
Custom element rules still apply: the name starts lowercase and contains a hyphen, so <yq-counter> works and <counter> doesn't. define throws on an invalid name instead of letting it fail quietly in the page.
Template, behavior and style, in one place
Most setups keep structure, logic and style in three separate places. yq-sanyi keeps them in one definition that shares a scope, a reactive state and a lifecycle. That's where the name "trinity" comes from.
The template layer is a set of declarative directives that covers the usual cases:
<!-- list rendering with a stable key and a row index -->
<div yq-for="(task, index) in tasks" yq-key="id">
{{ index }}. {{ task.title }}
<button yq-on:click="toggle">toggle</button>
</div>
<!-- new in 0.4.0: nested yq-for at any depth, including on component tags -->
<div yq-for="group in groups" yq-key="id">
<b>{{ group.name }}</b>
<yq-row yq-for="row in group.items" yq-key="tag"></yq-row>
</div>
Conditionals are yq-if / yq-else-if / yq-else / yq-show; two-way binding is yq-model, with .trim / .number / .lazy; events are written yq-on:click.
Accessibility got its own pass in 0.4.0. When an aria-* binding resolves to an empty value, the attribute isn't rendered at all, so you don't get a dangling aria-label="" sitting in the DOM:
<input id="email"
yq-model.trim="email"
yq-on:focus="markFocus"
yq-on:blur="validate"
aria-label="{{ labelFor(name) }}"
aria-invalid="{{ error ? 'true' : '' }}">
Style isolation uses scope rewriting by default and doesn't require Shadow DOM. That's on purpose — global theming still reaches the component, and outside CSS isn't fully sealed off. When you do want a hard boundary, pass useShadowDOM to createScopedElement. A component's style is injected once and shared by every instance.
State comes back from script. Handlers receive the reactive state, and writing to it re-renders. Several writes in the same synchronous task are batched into one flush.
What's new in v0.4.0
Five merged batches, four areas.
Structure
Nested
yq-forat any depth, withyq-keyand the row index still workingaria-*bindings, plusyq-on:keydown/focus/blur/paste/wheeldefineAlias(displayName, realName)for hyphenated display namesReusable static fragments via
<template id="x">andyq.fragment(id, html)onRecoverplusyq.onError(fn)for structured failure handling
SSR and hydration
yq.parseTemplateDSD(src) // recognize <template shadowrootmode> from the server
yq.hydrate('#app') // adopt server-rendered DOM in place, no rebuild
The Node renderer entry is still a skeleton, and linkedom is an optional peer — that's what keeps the core dependency-free.
Reactive primitives
import { signal, effectPre, effectScope } from './core.mjs'
const count = signal(0)
const off = count.subscribe(v => render(v))
count.get() // 0
count.set(5) // notifies subscribers
count.peek() // 5 — reads without tracking
off() // unsubscribe
There's also effectPre (runs synchronously, returns a stop function) and effectScope (disposal in a group, with an idempotent stop()). derived now works out on its own whether a dependency is a signal or a state.
Ecosystem
DevTools extension for Chrome and Firefox (manifest v3)
React / Vue type wrappers (
.d.tsonly, ≤ 1 kB)A live playground in
examples/playground.htmlNew examples: SSR hydration, accessible form, nested lists, strict-CSP
Performance is a CI gate
Four performance budgets run in CI, and missing one fails the build, so a rendering regression can't land:
| Metric | Budget | Measured (p50) |
|---|---|---|
first-interactive — 3000-row cold boot to a click that reaches the DOM |
≤ 1000 ms | ~127 ms |
update-latency — state write replacing all 3000 rows until the DOM shows it |
≤ 200 ms | ~21 ms |
scroll-fps — 2000 rows, 200-row window, 4 rows per frame |
≥ 55 fps | ~880 fps (derived from p50 frame cost) |
scroll-frame-ops — DOM mutations to advance the window one frame |
≤ 900 ops/frame | 705 ops/frame |
The last one is my favorite. It counts DOM operations instead of timing them. One scroll frame always costs exactly 705 mutations, so a fast laptop and a loaded CI runner report the same number, and that catches regressions timing alone can't see.
Under the hood, the static skeleton is cloned once and updates write only the bound slots. No subtree rebuilds, no virtual-DOM diffing.
Trade-offs
SSR is Declarative-Shadow-DOM based and opt-in. The Node renderer is a skeleton, and browsers without DSD support fall back to client-side rendering.
The bundle is over its target. The 0.4.0 goal was ≤ 11 kB gzipped for the core; it landed at 12.58 kB (
core.mjs) and 12.78 kB (core.global.js), about 1.6 kB over. Every milestone added net-positive source bytes, and esbuild already runs--minify, so squeezing harder yields less than 100 bytes. Getting under the target needs real feature cuts, tracked for v0.4.1. This release raises the CI size ceiling from 12 kB to 13 kB so the gate matches the size that actually shipped, instead of leaving a red check forever.No CLI, and no non-browser target. The core is browser-first on purpose.
I'd rather write these down than dress them up. Picking a component layer is a trade-off, and the limits are part of the decision.
Try it
Repository: https://github.com/YQteam-hq/yq-sanyi
v0.4.0 release assets (
yq-sanyi-0.4.0.zip/.tar.gz): https://github.com/YQteam-hq/yq-sanyi/releases/tag/v0.4.0Tutorials:
docs/tutorial.md(English),docs/i18n/zh-CN/tutorial.md(Chinese)Runnable examples:
examples/— playground, SSR hydration, a11y form, nested lists, CSP
If you try it on a real page, I'd like to hear where it breaks. Issues and PRs are welcome.





Top comments (0)