A lightweight, zero-dependency web component framework.
Build modular web apps with simple HTML components. No virtual DOM, no complex tooling required.
- Quick Start
- Installation
- Core Concepts
- Built-in Elements & Directives
- Event Bus
- Element References
- Lazy Loading
- API Reference
- Using with Vite
- Benchmarks
- Examples
- Documentation
- Security & Trust Model
- Contributing
- License
Save this as counter.html:
<div class="counter">
<div class="count-display">{count}</div>
<div class="buttons">
<button onclick="count--">β</button>
<button onclick="count = 0">Reset</button>
<button onclick="count++">+</button>
</div>
<p>Double: {count * 2} | Squared: {count * count}</p>
</div>
<script>
let count = 0;
</script>
<style>
.counter {
text-align: center;
padding: 2rem;
}
.count-display {
font-size: 4rem;
font-weight: bold;
}
button {
padding: 0.5rem 1rem;
margin: 0.25rem;
cursor: pointer;
}
</style>In your index.html, import LadrillosJS from the CDN, register the
component, and drop the tag anywhere on the page:
<!DOCTYPE html>
<html>
<head>
<script type="module">
import { registerComponent } from "https://cdn.jsdelivr.net/npm/ladrillosjs@2/dist/index.js";
registerComponent("my-counter", "./counter.html");
</script>
</head>
<body>
<my-counter></my-counter>
</body>
</html>Components are fetched over HTTP, so serve the folder with any static server
(opening the file directly via file:// won't work β browsers block fetch
from local files):
npx serve # or: python -m http.server 8080That's it! Your reactive component is ready. π
π§± Starting a real project? Copy a template instead:
templates/cdn-starter(no build step) ortemplates/vite-starter(Vite). Both come with a page shell, CSP, error reporting, and two example components.
π Want more? The full documentation covers every feature step by step, from your first component to building a design system.
LadrillosJS v2 is distributed as native ES modules. Import directly from a CDN:
<!-- ES Module (recommended) -->
<script type="module">
import {
registerComponent,
registerComponents,
} from "https://cdn.jsdelivr.net/npm/ladrillosjs@2/dist/index.js";
registerComponent("my-component", "./component.html");
</script>
<!-- Also available on unpkg -->
<script type="module">
import { registerComponent } from "https://unpkg.com/ladrillosjs@2/dist/index.js";
</script>Note: LadrillosJS v2 is ESM-only. Legacy UMD/IIFE global builds are not published to npm.
npm install ladrillosjsimport { registerComponent, registerComponents } from "ladrillosjs";
// Single component
registerComponent("my-counter", "./components/counter.html");
// Multiple components
await registerComponents([
{ name: "app-header", path: "./components/header.html" },
{ name: "app-footer", path: "./components/footer.html" },
]);// Full API
import { registerComponent, $emit, $listen } from "ladrillosjs";
// Core only (no lazy loading, no event bus)
import { registerComponent } from "ladrillosjs/core";
// Lazy loading strategies only
import { lazyOnVisible, lazyOnIdle } from "ladrillosjs/lazy";
// Event bus only
import { $emit, $listen } from "ladrillosjs/events";Use {expression} to display reactive data. Any JavaScript expression works:
<h1>{title}</h1>
<p>Hello, {user.name}!</p>
<span>Total: {items.length} items</span>
<p>Is adult: {age >= 18 ? 'Yes' : 'No'}</p>Just declare variables with let β changes automatically update the DOM:
<script>
let count = 0;
let user = { name: "Alice", role: "Developer" };
let items = ["Apple", "Banana", "Cherry"];
</script>Attach events directly in HTML with inline expressions or function calls:
<button onclick="count++">Increment</button>
<button onclick="handleClick()">Click me</button>
<input onkeyup="search(event.target.value)" />
<form onsubmit="handleSubmit(event)">...</form>Or use the $on: directive with modifiers β dot-separated flags for
common patterns like preventDefault, key filtering, and modifier keys:
<form $on:submit.prevent="handleSubmit()">...</form>
<input $on:keyup.enter="search()" />
<textarea $on:keydown.ctrl.s.prevent="save()"></textarea>
<div $on:click.self="closeModal()">...</div>
<button $on:click.once="trackFirstClick()">Buy</button>See the Event Modifiers reference for every modifier and combination.
Use $bind to sync form inputs with state:
<input type="text" $bind="username" placeholder="Enter name" />
<p>Hello, {username}!</p>
<textarea $bind="bio"></textarea>
<select $bind="country">
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</select>
<script>
let username = "";
let bio = "";
let country = "us";
</script>Use <if>, <else-if>, and <else> to conditionally render elements:
<if condition="status === 'loading'">Loading...</if>
<else-if condition="status === 'error'">Something went wrong!</else-if>
<else>Content loaded successfully!</else>
<script>
let status = "loading";
</script>Use <show> to toggle visibility without removing from DOM (uses display: none):
<show condition="isVisible">I can be shown or hidden</show>
<button onclick="isVisible = !isVisible">Toggle</button>
<script>
let isVisible = true;
</script>
<show>vs<if>:<show>toggles CSS display (children stay in DOM),<if>adds/removes children entirely.
Use <for> to render lists with optional index and key:
<!-- Simple list -->
<ul>
<for each="fruit in fruits">
<li>π {fruit}</li>
</for>
</ul>
<!-- With index -->
<for each="(item, index) in items">
<div>#{index + 1}: {item}</div>
</for>
<!-- Object array with key -->
<for each="user in users" key="user.id">
<div>
<span>{user.avatar}</span>
<span>{user.name}</span>
<span>{user.role}</span>
</div>
</for>
<script>
let fruits = ["Apple", "Banana", "Cherry"];
let items = ["First", "Second", "Third"];
let users = [
{ id: 1, name: "Alice", role: "Developer", avatar: "π©βπ»" },
{ id: 2, name: "Bob", role: "Designer", avatar: "π¨βπ¨" },
];
</script>Use <lazy> to defer rendering until a trigger fires (viewport, idle, delay, interaction, media):
<lazy margin="100px">
<heavy-chart></heavy-chart>
</lazy>
<lazy interaction="click,focus">
<support-chat></support-chat>
</lazy>| Element / Directive | Purpose | Example |
|---|---|---|
<if> |
Conditional render | <if condition="isLoggedIn">Welcome!</if> |
<else-if> |
Chained condition | <else-if condition="isGuest">Hello Guest</else-if> |
<else> |
Fallback | <else>Please log in</else> |
<show> |
CSS visibility toggle | <show condition="isOpen">Menu</show> |
<for> |
Loop rendering | <for each="item in items"><li>{item}</li></for> |
<for> (indexed) |
Loop with index | <for each="(item, i) in items">β¦</for> |
<for key="β¦"> |
List optimization | <for each="u in users" key="u.id">β¦</for> |
<lazy> |
Defer rendering | <lazy idle><analytics-pixel /></lazy> |
$bind |
Two-way binding | <input $bind="email" /> |
$ref |
Element reference | <input $ref="inputEl" /> |
$on: + modifiers |
Events with modifiers | <form $on:submit.prevent="save()"> |
$no:bind |
Escape {} binding |
<code $no:bind>{literal}</code> |
Communicate between components using $emit and $listen:
<button onclick="sendMessage()">Send Message</button>
<script>
let message = "Hello from sender!";
function sendMessage() {
$emit("my-event", { text: message, time: new Date().toLocaleTimeString() });
}
</script><div>
<p>Received: {receivedMessage}</p>
</div>
<script>
let receivedMessage = "Waiting...";
$listen("my-event", (data) => {
receivedMessage = data.text;
});
</script>Use $ref to get direct DOM access for advanced manipulation:
<input type="text" $ref="inputEl" placeholder="Click button to focus" />
<button onclick="focusInput()">Focus Input</button>
<canvas $ref="canvas" width="200" height="100"></canvas>
<button onclick="draw()">Draw on Canvas</button>
<script>
function focusInput() {
$refs.inputEl.focus();
$refs.inputEl.select();
}
function draw() {
const ctx = $refs.canvas.getContext("2d");
ctx.fillStyle = "blue";
ctx.fillRect(10, 10, 100, 50);
}
</script>Load components only when needed to improve initial page load:
import {
registerComponents,
lazyOnVisible,
lazyOnIdle,
lazyOnInteraction,
lazyOnMedia,
lazyOnDelay,
} from "ladrillosjs";
await registerComponents([
// Load when visible in viewport
{
name: "lazy-footer",
path: "./footer.html",
lazy: lazyOnVisible({ rootMargin: "100px" }),
},
// Load when browser is idle
{
name: "analytics-widget",
path: "./analytics.html",
lazy: lazyOnIdle(5000), // timeout: 5s max wait
},
// Load on user interaction
{
name: "modal-dialog",
path: "./modal.html",
lazy: lazyOnInteraction(["click", "focusin"]),
},
// Load based on media query
{
name: "mobile-nav",
path: "./mobile-nav.html",
lazy: lazyOnMedia("(max-width: 768px)"),
},
// Load after delay
{
name: "chat-widget",
path: "./chat.html",
lazy: lazyOnDelay(3000), // 3 second delay
},
]);| Strategy | Use Case |
|---|---|
lazyOnVisible |
Below-fold content, footers, image galleries |
lazyOnIdle |
Non-critical features, analytics |
lazyOnInteraction |
Modals, dropdowns, tooltips |
lazyOnMedia |
Mobile/desktop specific components |
lazyOnDelay |
Chat widgets, notifications |
Force a lazy component to load immediately by adding the eager attribute:
<lazy-footer eager></lazy-footer>registerComponent(name, path, useShadowDOM?, lazy?)| Parameter | Type | Default | Description |
|---|---|---|---|
name |
string | required | Tag name (must include hyphen) |
path |
string | required | Path to .html component file |
useShadowDOM |
boolean | true |
Enable Shadow DOM encapsulation |
lazy |
boolean | LazyStrategy | false |
Lazy loading configuration |
// Basic usage
registerComponent("my-button", "./button.html");
// Without Shadow DOM (for global CSS)
registerComponent("my-nav", "./nav.html", false);
// With lazy loading
registerComponent("my-footer", "./footer.html", true, lazyOnVisible());Register multiple components with parallel fetching:
const result = await registerComponents([
{ name: "app-header", path: "./header.html" },
{ name: "app-footer", path: "./footer.html", lazy: lazyOnVisible() },
{ name: "user-card", path: "./user-card.html", useShadowDOM: false },
]);
// Returns: { success: [...], failed: [...], skipped: [...] }Infer the component tag name from the file path:
await $use("./components/user-card.html"); // Registers as <user-card>Force a lazy component to load immediately from JavaScript:
import { loadLazyComponent } from "ladrillosjs";
await loadLazyComponent("my-lazy-footer");Configure framework-level options (optional):
import { configure } from "ladrillosjs";
configure({
cacheSize: 50, // Component LRU cache size (default: 25)
onError: (err, context) => telemetry.capture(err, { context }),
delegateLoopEvents: true, // Opt-in loop event delegation (default: false)
});Development builds include actionable LJSxxx diagnostics with component and
file context, a suggested fix, and a link to the
error reference. Vite and other bundlers that
honor the development package condition select this build automatically. To
select it explicitly, import from ladrillosjs/dev (or
ladrillosjs/core/dev). Production builds retain coded errors but remove
development-only warnings.
The onError callback is also suitable for telemetry. Coded framework errors
are instances of LadrillosError and expose code, docsUrl, hint,
componentContext, and the original cause.
By default every handler inside a <for> row gets its own listener. With
delegateLoopEvents: true, eligible handlers share one listener per event
type on the loop's container β thousands of rows, a handful of listeners.
Templates and handler code don't change at all, and render performance is
the same either way; enable it when listener count itself matters (very
large lists, memory-constrained devices, many handlers per row).
Fine print:
- Non-bubbling events (
focus,blur,mouseenter, β¦) and handlers using the.self,.capture,.once, or.passivemodifiers automatically keep per-element listeners..stop,.prevent, and key/system modifiers work identically in both modes. event.currentTargetinside a delegated handler is the list container, not the row element (event.targetis unaffected).- If you manually attach your own listener on an element inside a row and
call
stopPropagation(), delegated handlers above it won't fire. - Call
configure()before your components render; already-rendered loops keep the mode they were created with.
| Function | Description |
|---|---|
$emit(event, data) |
Broadcast an event to all listeners |
$listen(event, callback) |
Subscribe to an event |
LadrillosJS works seamlessly with Vite for production builds:
npm install ladrillosjs vite// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
// LadrillosJS components work out of the box!
});// main.js
import { registerComponent } from "ladrillosjs";
registerComponent("my-counter", "./components/counter.html", false);See the samples/ directory for complete examples:
samples/vite-sample/β Basic Vite setupsamples/vite-basic-site/β Multi-component sitesamples/ladrillos-demo/β Full feature showcase
A js-framework-benchmarkβstyle
suite lives in benchmarks/. It renders the same keyed
1,000-row list in LadrillosJS, React 18 (keyed, memoized rows β the
idiomatic fast path), and hand-optimized vanilla JS, and measures each
operation from invocation until the live DOM reflects the change.
Medians of 10 runs, headless Chromium on an Apple M5 Pro,
ladrillosjs@2.0.0-beta.11:
| Operation | LadrillosJS | React 18.3 (keyed, memoized rows) | Vanilla JS (hand-optimized) |
|---|---|---|---|
| create 1,000 rows | 3.2 ms | 4.2 ms | 1.7 ms |
| replace all 1,000 rows | 3.8 ms | 6.3 ms | 2 ms |
| partial update (every 10th of 1,000) | 1 ms | 1.2 ms | 0.1 ms |
| select row | 0.6 ms | 0.3 ms | 0 ms |
| swap 2 rows | 0.9 ms | 3 ms | 0 ms |
| remove row | 0.8 ms | 0.9 ms | 0.1 ms |
| append 1,000 to 1,000 rows | 3.6 ms | 3.9 ms | 1.2 ms |
| clear 1,000 rows | 1.1 ms | 4 ms | 0.8 ms |
| create 10,000 rows | 29.1 ms | 228.3 ms | 11.3 ms |
| JS payload (min+gzip) | 27.7 KB | 47 KB | ~1 KB |
| JS heap after 1,000 rows | 2.4 MB | 6.2 MB | 1.3 MB |
How to read this honestly:
- Every operation lands well within a single 60 fps frame (16.7 ms): updates on a 1,000-row list take about a millisecond, and bulk creation of 1,000 rows is under 5 ms.
- LadrillosJS beats React on bulk creation (and by nearly 8Γ on 10,000 rows), full replaces, row swaps, and clearing, while shipping ~40% less JS and using ~60% less memory for the same UI.
- React 18 remains faster on single-row selection, and partial updates are effectively a tie. We publish these numbers to track and improve them β not to claim LadrillosJS wins everything.
- Vanilla JS is the baseline.
Reproduce it yourself (no benchmark numbers should be trusted otherwise):
cd benchmarks && npm install && npm run benchMethodology, fairness notes, and raw samples: benchmarks/README.md and benchmarks/results.json.
A complete CRUD example combining all directives:
<div class="todo-app">
<form onsubmit="addTodo(event)">
<input type="text" $bind="newTodo" placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
<ul>
<for each="todo in todos" key="todo.id">
<li>
<input type="checkbox" onclick="toggleTodo(todo.id)" />
<span class="{todo.completed ? 'done' : ''}">{todo.text}</span>
<button onclick="removeTodo(todo.id)">ποΈ</button>
</li>
</for>
</ul>
<if condition="todos.length === 0"><p>No todos yet!</p></if>
</div>
<script>
let todos = [
{ id: 1, text: "Learn LadrillosJS", completed: false },
{ id: 2, text: "Build something awesome", completed: false },
];
let newTodo = "";
let nextId = 3;
function addTodo(event) {
event.preventDefault();
if (newTodo.trim()) {
todos = [...todos, { id: nextId++, text: newTodo, completed: false }];
newTodo = "";
}
}
function toggleTodo(id) {
todos = todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t,
);
}
function removeTodo(id) {
todos = todos.filter((t) => t.id !== id);
}
</script>π‘ Check the samples/ folder for more examples including lazy loading, event bus communication, and real-world patterns.
Full guides live in the docs/ folder:
Upgrading from v1? See the Migration Guide. Using TypeScript? See the TypeScript guide.
Component HTML is trusted code. Treat a .html component the same way you'd
treat a .js file you import.
LadrillosJS runs the <script> in each component, evaluates your {expression}
bindings, and compiles your inline event handlers as JavaScript with full access
to the page (window, document, fetch, localStorage, β¦). This is by design
β it's what lets components feel like plain HTML + JS with no build step, exactly
like the template in a Vue single-file component or a Svelte .svelte file. It
also means the framework is not a sandbox: you should only register and run
component files that you wrote or otherwise trust.
Practical guidance:
- β Do author your own components and load them from your own origin.
- β Don't fetch and register component HTML from untrusted third parties, or build component files by concatenating user input into a template β that's equivalent to running arbitrary code from that source.
- β
Dynamic data is fine. Rendering untrusted data β API responses,
user comments, list items β through bindings and
<for>loops is safe: values are set viatextContent/setAttributeand passed to event handlers as arguments, never executed as code. (Interpolating intoinnerHTMLyourself, or into a<script>/handler you assemble by hand, is not β same rule as above.)
In short: untrusted data through LadrillosJS's bindings is safe; untrusted templates/scripts are not, because those are your application code.
By default components are compiled in the browser (that's what "no build step"
costs), so a page using LadrillosJS needs 'unsafe-eval':
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-eval';
style-src 'self';
connect-src 'self';Notably, 'unsafe-inline' for scripts is not needed β inline onclick
attributes are removed and reattached via addEventListener() β and neither is
blob:. 'unsafe-inline' for styles is not needed either: component CSS is
adopted as a constructed stylesheet rather than injected as <style>.
require-trusted-types-for 'script' is supported on the build below.
If you use Vite, you can drop 'unsafe-eval'. Add
@ladrillosjs/vite-plugin and import from
ladrillosjs/csp; components are compiled at build time instead, with no
change to your components or your code:
// vite.config.js
import ladrillos from "@ladrillosjs/vite-plugin";
export default { plugins: [ladrillos()] };See Content Security Policy for the full breakdown, both policies, and the verification results.
Contributions are welcome! Here's how you can help:
- Fork the repository
- Create a feature branch:
git checkout -b feature/amazing-feature - Commit your changes:
git commit -m 'Add amazing feature' - Push to the branch:
git push origin feature/amazing-feature - Open a Pull Request
git clone https://github.com/drubiodev/LadrillosJS.git
cd LadrillosJS
npm install
npm run dev # Watch mode for development
npm run build # Build for production
npm test # Unit tests (Vitest)MIT License β see LICENSE for details.
Built with β€οΈ by Daniel Rubio
