Zero-build, zero-dependency, standalone, reactive, lightweight UI framework. ~31KB minified (~11KB gzipped).
Documentation - Examples - LLM Reference (~4,700 tokens) - npm
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<div id="app"></div>
<script>
blok.mount('#app', {
state: { count: 0 },
methods: {
inc() { this.count++ },
dec() { this.count-- },
},
view: ($) => ({
div: { children: [
{ h1: { text: $.count } },
{ button: { click: 'dec', text: '-' } },
{ button: { click: 'inc', text: '+' } },
] }
})
})
</script>
No virtual DOM. Views are plain JavaScript objects. State is reactive - when count changes, the bound h1 updates automatically.
loading and error state for async methodson, JS properties via props, model on value-based elementsjavascript: and dangerous URIs blocked on href/src attributesmount() adopts itblok.component('TodoItem', {
props: ['todo'],
methods: {
remove() { this.emit('remove', this.todo) }
},
view: ($) => ({
li: { children: [
{ input: { type: 'checkbox', model: $.todo.done } },
{ span: { text: $.todo.text } },
{ button: { click: 'remove', text: 'x' } },
] }
})
})
Use components by name in templates. Pass props, listen to events with on_ prefix:
{ each: $.todos, as: 'todo', key: 'id', children: [
{ TodoItem: { todo: $.todo, on_remove: 'handleRemove' } }
] }
Global state shared across components. Async methods get automatic loading/error tracking:
blok.store('auth', {
state: { user: null },
computed: {
isLoggedIn() { return this.user !== null }
},
methods: {
async login(email, password) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
this.user = await res.json()
},
logout() { this.user = null }
}
})
// In templates
{ when: $.store.auth.loading.login, children: [
{ p: 'Signing in...' }
] }
{ when: $.store.auth.error.login, children: [
{ p: { class: 'error', text: $.store.auth.error.login } }
] }
// In methods
this.store.auth.login(email, pw)
this.store.auth.isLoggedIn
blok.store() returns the store, so code outside components can read, write and watch it:
const auth = blok.store('auth') // look up a registered store
const stop = blok.watch(() => auth.$state, state => {
localStorage.setItem('auth', JSON.stringify(state))
}, { deep: true })
auth.$state = { user: null } // assigns each key, like Object.assign
blok.mount('#app', {
routes: [
{ path: '/', component: 'Home' },
{ path: '/product/:id', component: 'ProductDetail' },
{ path: '/admin', component: 'Admin', guard: 'requireAuth' },
{ path: '*', component: 'NotFound' },
],
guards: {
requireAuth(to, from) {
if (!this.store.auth.isLoggedIn) return '/login'
return true
}
},
view: ($) => ({
div: { children: [
{ a: { href: '/', link: true, text: 'Home' } },
{ a: { href: '/admin', link: true, text: 'Admin' } },
{ div: { route: true } },
] }
})
})
Access route data in components via this.route.params, this.route.query, and navigate programmatically with this.navigate('/path').
Views are plain objects. The $ proxy creates reactive references resolved at render time. A ref is a path, not a value, so JS operators (!, ? :, >) do not see its value: use $.not, the function form, or a computed.
// Conditionals
{ when: $.isLoggedIn, children: [{ p: 'Welcome!' }] }
{ when: $.not.isLoggedIn, children: [{ p: 'Please log in' }] }
// Function form - for expressions with operators
{ when: ($) => !$.isLoggedIn, children: [{ p: 'Please log in' }] }
{ when: ($) => $.count > 0 && $.isReady, children: [{ p: 'Go!' }] }
// Loops
{ each: $.items, as: 'item', key: 'id', children: [
{ li: { text: $.item.name } }
] }
// Two-way binding
{ input: { type: 'text', model: $.search } }
{ select: { model: $.category, children: [...] } }
// JS properties (arrays/objects for web components)
{ 'data-grid': { props: { rows: $.rows } } }
// Classes (string, object, or array)
{ div: { class: { active: $.isActive, disabled: $.isOff } } }
{ li: { class: { active: ($) => $.item.id === $.selectedId } } }
// Events
{ button: { click: 'save', text: 'Save' } }
{ button: { click: $.save, text: 'Save' } } // method ref
{ 'wa-dialog': { on: { 'wa-after-hide': 'onClose' } } }
{ button: { click: 'remove(item)', text: 'x' } }
{ form: { submit: 'handleSubmit', children: [...] } }
// Slots
{ Card: { title: 'Hello', children: [
{ p: 'This goes into the slot' }
] } }
While BlokJS works without a build step, the Vite plugin adds automatic component and store registration from file directories, bundling into optimized output, and hot module replacement during development.
Scaffold a new project:
npm create blokjs my-app
cd my-app
npm install
npx vite
Or add to an existing Vite project:
npm install blokjs vite-plugin-blokjs
// vite.config.js
import { defineConfig } from 'vite'
import { blokjs } from 'vite-plugin-blokjs'
export default defineConfig({
plugins: [blokjs()],
})
// main.js
import { mount } from '@maleta/blokjs'
import 'virtual:blokjs'
mount('#app', {
view: ($) => ({ counter: {} }),
})
The plugin auto-discovers files in components/ and stores/ directories, registering each by filename. Files prefixed with _ are ignored.
components/counter.js -> component('counter', ...)
stores/app.js -> store('app', ...)
components/_helper.js -> ignored
@maleta/blokjs-pocketbase turns a PocketBase collection into a BlokJS store: records load into items, follow realtime changes, and get loading/error tracking like any async store method. pbAuth does the same for the signed-in user.
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.28/dist/pocketbase.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs-pocketbase/dist/blokjs-pocketbase.min.js"></script>
const pb = new PocketBase('/')
blok.store('todos', blokPocketbase.pbCollection(pb, 'todos', { sort: '-created' }))
blok.mount('#app', {
mount() { this.store.todos.connect() },
methods: {
add() { this.store.todos.save({ title: 'New todo' }) },
},
view: ($) => ({ div: { children: [
{ button: { click: 'add', text: 'Add' } },
{ each: $.store.todos.items, as: 'todo', key: 'id', children: [
{ p: { text: $.todo.title } },
] },
] } }),
})
See the package README for the full API and examples/pocketbase for a runnable app.
Pages render in the browser, so clients that do not run JavaScript (crawlers, link previews, site reviews) see an empty mount node. blokjs-prerender writes the rendered HTML of each route into the page, and blok.mount() then adopts that HTML instead of rendering it again. The app keeps working without this step.
bunx blokjs-prerender index.html # every route without :param, * or guard
bunx blokjs-prerender index.html --routes / /about --out dist
bunx blokjs-prerender index.html --inline --out dist # one self-contained file per route
In a server or a custom build script, renderToString from @maleta/blokjs/server returns the same HTML:
import { component, renderToString } from '@maleta/blokjs/server'
const html = renderToString(appOptions, { url: '/about' })
// <div id="app" data-blok-ssr>${html}</div>
See the package README for what runs during prerendering and what does not.
BlokJS supports all ES2020-compliant browsers (96%+ global coverage). IE is not supported.
pnpm install
pnpm build # build all packages
pnpm test # run tests
MIT