DEV Community

YQteam
YQteam

Posted on

yq-sanyi v0.4.0: define a component once, use it as a real HTML tag anywhere

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
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Then, in plain HTML:

<yq-counter></yq-counter>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

One definition, three parts, one scope: template, behavior and scoped style share one scope, one reactive state and one lifecycle

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>
Enter fullscreen mode Exit fullscreen mode

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' : '' }}">
Enter fullscreen mode Exit fullscreen mode

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-for at any depth, with yq-key and the row index still working

  • aria-* bindings, plus yq-on:keydown / focus / blur / paste / wheel

  • defineAlias(displayName, realName) for hyphenated display names

  • Reusable static fragments via <template id="x"> and yq.fragment(id, html)

  • onRecover plus yq.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
Enter fullscreen mode Exit fullscreen mode

The Node renderer entry is still a skeleton, and linkedom is an optional peer — that's what keeps the core dependency-free.

SSR -> Declarative Shadow DOM -> hydrate in place: server-rendered markup is adopted, not rebuilt

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
Enter fullscreen mode Exit fullscreen mode

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.ts only, ≤ 1 kB)

  • A live playground in examples/playground.html

  • New 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.

Four CI performance budgets, all green: green bar is the measured p50, dashed line is the budget

Under the hood, the static skeleton is cloned once and updates write only the bound slots. No subtree rebuilds, no virtual-DOM diffing.

Clone the skeleton once, patch only the bound slots: updates write changed slots, with no subtree rebuild and no virtual-DOM diff

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

If you try it on a real page, I'd like to hear where it breaks. Issues and PRs are welcome.

Top comments (0)