Caution
This is mostly an experiment created for the planned PocketBase UI rewrite to allow frontend plugins support.
Don't use it yet - it hasn't been actually tested in real applications and it may change without notice!
Shablon ("template" in Bulgarian) is a ~5KB JS framework that comes with deeply reactive state management, plain JS extendable templates and hash-based router.
Shablon has very small learning curve (4 main exported functions) and it is suitable for building Single-page applications (SPA):
- State: store(obj) and watch(callback)
- Template: t.[tag](attrs, ...children)
- Router: router(routes, options)
There is no dedicated "component" structure. Everything is essentially plain DOM elements sprinkled with a little reactivity.
Below is an example Todos list "component" to see how it looks:
Shablon is not as pretty as Svelte but it strives for similar developer experience.
You can also check the example folder for a showcase of a minimal SPA with 2 pages.
The default IIFE bundle will load all exported Shablon functions in the global context. You can find the bundle file at dist/shablon.iife.js (or use a CDN pointing to it):
Alternatively, you can load the package as ES module by using the dist/shablon.es.js file.
- browsers:
<script type="module"> import { t, store, watch, router } from "/path/to/dist/shablon.es.js" const data = store({ count: 0 }) ... </script>'><!-- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules --> <script type="module"> import { t, store, watch, router } from "/path/to/dist/shablon.es.js" const data = store({ count: 0 }) ... </script>
store(obj) returns a reactive Proxy of the specified plain object.
The keys of an obj must be "stringifiable" because they are used internally to construct a path to the reactive value.
The values can be any valid JS value, including nested arrays and objects (aka. it is recursively reactive).
Getters are also supported and can be used as reactive computed properties. The value of a reactive getter is "cached", meaning that even if one of the getter dependency changes, as long as the resulting value is the same there will be no unnecessary watch events fired.
Multiple changes from one or many stores are also automatically batched in a microtask. For example:
Watch registers a callback function that fires once on initialization and every time any of its store reactive dependencies change.
It returns a "watcher" object that could be used to unwatch the registered listener.
For example:
Note that for reactive getters, initially the watch callback will be invoked twice because we register a second internal watcher to cache the getter value.
t.[tag](attrs, ...children)t.[tag](attrs, ...children) constructs and returns a new DOM element (aka. document.createElement(tag)).
tag could be any valid HTML element name - div, span, hr, img, registered custom web component, etc.
attrs is an object where the keys are:
- valid element's JS property (note that some HTML attribute names are different from their JS property equivalent, e.g. class vs className, for vs htmlFor, etc.)
- regular or custom HTML attribute if it has html- prefix (it is stripped from the final attribute), e.g. html-data-name
The attributes value could be a plain JS value or reactive function that returns such value (e.g. () => data.count).
children is an optional list of child elements that could be:
- plain text (inserted as TextNode)
- single tag
- array of tags
- reactive function that returns any of the above
When a reactive function is set as attribute value or child, it is invoked only when the element is mounted and automatically "unwatched" on element removal (with slight debounce to minimize render blocking).
Lifecycle attributes
Each constructed tag has 3 additional optional lifecycle attributes:
- onmount: func - optional callback called when the element is inserted in the DOM
- onunmount: func - optional callback called when the element is removed from the DOM
- rid: any - "replacement id" is an identifier based on which we can decide whether to reuse the element or not during rerendering (e.g. on list change); the value could be anything comparable with ==
router(routes, options = { fallbackPath: "#/", transition: true }) initializes a hash-based client-side router by loading the provided routes configuration and listens for hash navigation changes.
routes is a key-value object where:
- the key must be a string path such as #/a/b/{someParam}
- value is a route handler function that executes every time the page hash matches with the route's path (the route handler can return a "destroy" function that is invoked when navigating away from that route)
Note that by default the router expects to have at least one "#/" route that will be also used as fallback in case the user navigate to a missing page.
For example:
No extensive testing or benchmarks have been done yet but for the simple cases it should perform as fast as it could get because we update only the targeted DOM attribute when possible (furthermore multiple store changes are auto batched per microtask to ensure that watchers are not invoked unnecessary).
For example, the expression t.div({ textContent: () => data.title }) is roughly the same as the following pseudo-code:
Conditional rendering tags as part of a reactive child function is a little bit more complicated though. By default when such function runs due to a store dependency change, the old children will be removed and the new ones will be inserted on every call of that function which could be unnecessary if the tags hasn't really changed.
To avoid this you can specify the rid attribute which instructs Shablon to reuse the same element if the old and new rid are the same minimizing the DOM operations. For example:
Other things that could be a performance bottleneck are the lifecycle attributes (onmount, onunmount) because currently they rely on a global MutationObserver which could be potentially slow for deeply nested elements due to the nature of the current recursive implementation (this will be further evaluated during the actual integration in PocketBase).
Shablon DOES NOT perform any explicit escaping on its own and it relies on:
- modern browsers to perform TextNode (when a child is a plain string) and attributes value escaping out of the box for us
- developers to use the appropriate safe JS properties (e.g. textContent instead of innerHTML)
There could be some gaps and edge cases so I strongly recommend registering a Content Security Policy (CSP) either as meta tag or HTTP header to prevent XSS attacks.
If you are not sure why would you use Shablon instead of Svelte, Lit, Vue, etc., then I'd suggest to simply pick one of the latter because they usually have a lot more features, can offer better ergonomics and have abundance of tutorials.
Shablon was created for my own projects, and more specifically for PocketBase in order to allow writing dynamically loaded dashboard UI plugins without requiring a Node.js build step. Since I didn't feel comfortable maintaining UI plugins system on top of another framework with dozens other dependencies that tend to change in a non-compatible way over time, I've decided to try building my own with minimal API surface and that can be safely "frozen".
Shablon exists because:
- it can be quickly learned (4 main exported functions)
- it has minimal "magic" and no unsafe-eval (aka. it is Content Security Policy friendly)
- no IDE plugin or custom syntax highlighter is needed (it is plain JavaScript)
- the templates return regular JS Element allowing direct mutations
- it doesn't require build step and can be imported in the browser with a regular script tag
- it has no external dependencies and doesn't need to be updated frequently
- it is easy to maintain on my own (under 2000 LOC with tests)
Shablon is free and open source project licensed under the Zero-Clause BSD License (no attribution required).
Feel free to report bugs, but feature requests are not welcomed.
There are no plans to extend the project scope and once a stable PocketBase release is published it could be considered complete.
.png)

