Every time I start a small project — a landing page with a form, a quick prototype, a dashboard widget — I find myself reaching for React or Vue out of habit. Then I spend 10 minutes setting up a build pipeline for something that should take 10 minutes total.
So I built SensibleJS: a reactive UI library that works with plain HTML attributes. One <script> tag, zero configuration, ~10KB minified.
The simplest example
<script src="sensibljs.min.js" defer></script>
<input s-bind="name" placeholder="Your name">
<p s-bind="name">Hello, {name}!</p>
That's it. Type in the input, the paragraph updates. No initialization call, no mount function, no app component. SensibleJS detects your data and starts automatically.
Why I built this
I kept running into the same pattern:
- Need a small interactive feature on a page
- Pull in a framework
- Set up a bundler
- Write boilerplate
- Ship 50KB+ for a form with two inputs
The web platform is powerful enough that most small interactions don't need a virtual DOM, a component tree, or a build step. They need data binding and a few directives.
What it can do
SensibleJS has 19 directives that cover the common cases:
Two-way data binding
<input type="text" s-bind="username">
<input type="checkbox" s-bind="darkMode">
<input type="range" s-bind="volume" min="0" max="100">
<select s-bind="country">
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</select>
Every input type works: text, checkbox, radio, select, color, date, number, range.
Conditional rendering
<div s-if="isLoggedIn">Welcome back!</div>
<div s-if="!isLoggedIn">Please log in.</div>
<button s-if="items.length > 0">Checkout</button>
Any JavaScript expression works. The element's original display style (flex, grid, inline) is preserved — it doesn't force everything to display: block.
List rendering
<div s-for="task of tasks" s-key="task.id">
<span>#{task.id}</span>
<span>{task.text}</span>
<button onclick="removeTask(this)">remove</button>
</div>
Uses keyed reconciliation — existing DOM elements are reused, not rebuilt. Array mutations (push, pop, splice, reverse, etc.) are observed automatically.
Animated transitions
<div s-if="show" s-transition="fade">Fades in and out</div>
.fade-enter { opacity: 0; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-to { opacity: 1; }
.fade-leave-to { opacity: 0; }
Add s-transition="name" to any s-if element. SensibleJS applies CSS classes at each stage — you define the animation in CSS.
Dynamic styling and classes
<!-- Bind CSS properties to variables -->
<div s-css="background-color: {color}; opacity: {opacity / 100}">
Styled content
</div>
<!-- Toggle classes based on expressions -->
<input s-class="valid-border: email.includes('@'); invalid-border: !email.includes('@')">
Event handling
<!-- Simple click handler -->
<button s-click="count++">Clicked {count} times</button>
<!-- General events with modifiers -->
<input s-on="keydown.enter: submitForm()">
<form s-on="submit.prevent: handleSubmit()">
<!-- Click outside detection -->
<div s-unclick="dropdownOpen = false">
<!-- dropdown closes when clicking outside -->
</div>
Computed values and watchers
let store = {
data: {
price: { type: Number, default: 10 },
quantity: { type: Number, default: 1 },
subtotal: { computed: 'price * quantity' },
tax: { computed: 'subtotal * 0.07' },
total: { computed: 'subtotal + tax' }
}
};
Computed values update automatically when their dependencies change. Watchers let you react to changes with (newValue, oldValue).
And more
-
s-text/s-html— Set text content (safe) or innerHTML (raw) -
s-attr— Dynamic HTML attributes (disabled,href,aria-*) -
s-ref— Name elements and access them via$refsin expressions -
s-debounce— Delay input updates until the user stops typing -
s-cloak— Hide elements until data is bound (prevents template flash) -
s-data— Define variables inline in HTML without a store -
s-blur— Update binding on blur instead of keyup - Persistence — Variables save to localStorage automatically
The store
Define your data in a plain object:
<script>
let store = {
persist: true,
data: {
name: { type: String, default: '' },
count: { type: Number, default: 0 },
items: { type: Array, default: [] },
prefs: { type: Object, default: { theme: 'dark' } },
total: { computed: 'price * quantity' },
search: {
type: String,
default: '',
watch: function(newVal, oldVal) {
console.log('Changed:', oldVal, '→', newVal);
}
}
},
onInit: function(data) {
data.name = 'Ready at ' + new Date().toLocaleTimeString();
}
};
</script>
If you don't define a store, SensibleJS creates one automatically from the s-bind attributes in your HTML.
Security
Since expressions are evaluated at runtime, SensibleJS sandboxes them. Dangerous globals are blocked:
document, window, fetch, XMLHttpRequest, Function,
eval, Proxy, Reflect, setTimeout, setInterval
The sandbox also blocks constructor chain escapes (like (0).constructor.constructor("return this")()) and computed bracket notation bypasses. Safe globals like Math, parseInt, JSON, and console remain accessible.
What it's NOT for
Let me be upfront about the limitations:
- Not for SPAs. No router, no component system, no code splitting.
- Not for large apps. No virtual DOM diffing means re-renders hit the real DOM. Fine for dozens of reactive elements, not for thousands.
- Not for teams that need TypeScript. It's a single vanilla JS file.
- Not a framework. It's a tool for adding reactivity to HTML.
If you're building something complex, use React, Vue, Svelte, or whatever your team knows. SensibleJS is for the other 80% of cases — the ones where a framework is overkill.
How it works under the hood
-
Reactivity:
Object.definePropertygetters/setters on a data object. When a value changes, only elements that reference that variable are re-rendered. -
Array observation: Mutating methods (
push,pop,splice, etc.) are wrapped to trigger updates. -
Expression evaluation:
new Function()with blocked globals. Noteval— each expression compiles to a proper function. -
List reconciliation:
s-forusess-keyfor keyed diffing. Existing DOM nodes are reused when items move. - No virtual DOM: Changes go straight to the real DOM. This is simpler, faster for small UIs, and means zero memory overhead from a shadow tree.
Try it
CDN / direct download:
<script src="sensibljs.min.js" defer></script>
npm:
npm i sensibljs
Live demo: ricardoaponte.github.io/sensiblejs
GitHub: github.com/ricardoaponte/sensiblejs
The entire library is one file, ~1000 lines unminified. Read it, fork it, break it. MIT licensed.
I'd love feedback — what's missing? What would make you reach for this instead of adding Alpine.js or Petite-Vue? What would stop you?

Top comments (0)