A powerful, lightweight TypeScript template engine with reactive data binding, conditional rendering, loops, and event handling.
Template.Ts is a simple yet powerful template engine that allows you to create dynamic HTML templates with TypeScript. It provides an intuitive syntax for data binding, loops, conditionals, and event handling without the complexity of larger frameworks.
β¨ Simple Syntax - Easy-to-learn template directives
{{ expression }}- Text interpolation@for="array"- Loop through arrays@if="condition"- Conditional rendering@att:name="value"- Dynamic attribute binding@batt:name="condition"- Boolean attribute binding@prop:name="value"- Custom element property binding@on:event="handler"- Event handling
π Lightweight - Zero dependencies, minimal footprint
β‘ Efficient Updates - Only changed parts of the DOM are updated
π Type Safe - Full TypeScript support with proper typing
π― Framework Agnostic - Works with any project setup
Try it out right now: https://xoboto.github.io/template.ts/
npm install template.tsyarn add template.ts<script type="module">
import { TemplateBinder } from 'https://unpkg.com/xoboto.ts/dist/template.js';
</script><div id="app">
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<button @on:click="handleClick">Click Me</button>
</div>import { TemplateBinder } from 'template.ts';
const state = {
title: 'Hello World',
description: 'Welcome to Template.Ts',
handleClick: function() {
alert('Button clicked!');
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();// Update state
state.title = 'Updated Title';
state.description = 'Content has changed';
// Refresh the view
binder.update();Use double curly braces {{ }} to display data:
<div id="app">
<h1>{{ title }}</h1>
<p>User: {{ username }}</p>
<p>Score: {{ score }}</p>
</div>const state = {
title: 'Dashboard',
username: 'John Doe',
score: 100
};
const binder = new TemplateBinder('#app', state);
binder.bind();Iterate through arrays with the @for directive:
<ul>
<li @for="items">
{{ item.name }} - ${{ item.price }}
</li>
</ul>const state = {
items: [
{ name: 'Apple', price: 1.99 },
{ name: 'Banana', price: 0.99 },
{ name: 'Orange', price: 2.49 }
]
};
const binder = new TemplateBinder('#app', state);
binder.bind();Inside loops, you have access to:
item- Current item in the arrayindex- Current index (0-based)items- The entire array
Nested Loops: In nested loops, use parent to access the outer loop's item:
<tr @for="rows">
<td @for="columns">{{ getValue(parent, item.key) }}</td>
</tr>Show or hide elements based on conditions:
<div id="app">
<p @if="isLoggedIn">Welcome back, {{ username }}!</p>
<p @if="!isLoggedIn">Please log in.</p>
<ul @if="items.length > 0">
<li @for="items">{{ item.name }}</li>
</ul>
<p @if="items.length === 0">No items available.</p>
</div>const state = {
isLoggedIn: true,
username: 'Alice',
items: []
};
const binder = new TemplateBinder('#app', state);
binder.bind();Bind any HTML attribute dynamically:
<div id="app">
<input @att:type="inputType" @att:placeholder="placeholderText" />
<button @batt:disabled="isDisabled">Submit</button>
<div @att:class="containerClass">Content</div>
<img @att:src="imageUrl" @att:alt="imageAlt" />
</div>const state = {
inputType: 'email',
placeholderText: 'Enter your email',
isDisabled: true, // String value for disabled attribute
containerClass: 'container active',
imageUrl: '/images/logo.png',
imageAlt: 'Company Logo'
};
const binder = new TemplateBinder('#app', state);
binder.bind();Use @batt: for boolean attributes that should be present or absent based on a condition:
<div id="app">
<input type="checkbox" @batt:checked="isSelected" />
<button @batt:disabled="isLoading">Submit</button>
<option @batt:selected="isDefaultOption">Default</option>
<input @batt:required="isRequired" />
<details @batt:open="isExpanded">
<summary>Click to expand</summary>
<p>Content here</p>
</details>
</div>const state = {
isSelected: true, // Adds checked="" attribute
isLoading: false, // Removes disabled attribute
isDefaultOption: true, // Adds selected="" attribute
isRequired: true, // Adds required="" attribute
isExpanded: false // Removes open attribute
};
const binder = new TemplateBinder('#app', state);
binder.bind();Difference between @att: and @batt:
@att:disabled="value"β Setsdisabled="value"(always present with the value)@batt:disabled="condition"β Addsdisabled=""if condition is truthy, removes it if falsy
Use @prop: to bind JavaScript properties directly to custom elements (Web Components). Unlike @att: which sets HTML attributes, @prop: sets element properties:
<div id="app">
<data-table
@prop:columns="columns"
@prop:data="tableData"
@prop:searchable="true"
@prop:empty-message="emptyMessage">
</data-table>
</div>const state = {
columns: ['Name', 'Email', 'Role'],
tableData: [
{ name: 'John', email: 'john@example.com', role: 'Admin' },
{ name: 'Jane', email: 'jane@example.com', role: 'User' }
],
emptyMessage: 'No data available'
};
const binder = new TemplateBinder('#app', state);
binder.bind();Key Points:
- Attribute names in HTML are automatically converted from kebab-case to camelCase
@prop:empty-messagebecomeselement.emptyMessage- Perfect for passing complex data (objects, arrays) to Web Components
- Properties are set directly on the element object, not as HTML attributes
When to use @prop: vs @att::
- Use
@prop:for Web Components and custom elements - Use
@att:for standard HTML attributes on native elements
Attach event listeners to elements:
<div id="app">
<button @on:click="handleClick">Click Me</button>
<input @on:input="handleInput" @on:focus="handleFocus" />
<form @on:submit="handleSubmit">
<button type="submit">Submit</button>
</form>
</div>const state = {
handleClick: function() {
console.log('Button clicked!');
},
handleInput: function(e: Event) {
const value = (e.target as HTMLInputElement).value;
console.log('Input:', value);
},
handleFocus: function() {
console.log('Input focused');
},
handleSubmit: function(e: Event) {
e.preventDefault();
console.log('Form submitted');
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();Event handlers in loops receive the item and index as parameters:
<ul>
<li @for="todos">
<input type="checkbox" @on:change="toggleTodo" />
<span>{{ item.text }}</span>
<button @on:click="deleteTodo">Delete</button>
</li>
</ul>interface Todo {
text: string;
completed: boolean;
}
const state = {
todos: [
{ text: 'Learn Template.Ts', completed: false },
{ text: 'Build an app', completed: false }
],
toggleTodo: function(e: Event, item: Todo, index: number) {
this.todos[index].completed = !this.todos[index].completed;
binder.update();
},
deleteTodo: function(e: Event, item: Todo, index: number) {
this.todos.splice(index, 1);
binder.update();
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();Call functions within your templates:
<div id="app">
<p>Total: {{ calculateTotal() }}</p>
<ul>
<li @for="items" @att:class="getItemClass(index)">
{{ item.name }}
</li>
</ul>
</div>const state = {
items: [
{ name: 'Item 1', price: 10 },
{ name: 'Item 2', price: 20 }
],
calculateTotal: function() {
return this.items.reduce((sum, item) => sum + item.price, 0);
},
getItemClass: function(index: number) {
return index % 2 === 0 ? 'even' : 'odd';
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
</head>
<body>
<div id="app">
<h1>{{ title }}</h1>
<input
@att:value="newTodo"
@on:input="updateNewTodo"
@att:placeholder="'Add a new todo...'"
/>
<button @on:click="addTodo">Add</button>
<ul @if="todos.length > 0">
<li @for="todos" @att:class="item.completed ? 'completed' : ''">
<input
type="checkbox"
@batt:checked="item.completed"
@on:change="toggleTodo"
/>
<span>{{ item.text }}</span>
<button @on:click="deleteTodo">Delete</button>
</li>
</ul>
<p @if="todos.length === 0">No todos yet!</p>
<p @if="todos.length > 0">
Total: {{ todos.length }} | Completed: {{ completedCount() }}
</p>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>// app.ts
import { TemplateBinder } from 'template.ts';
interface Todo {
text: string;
completed: boolean;
}
interface TodoState {
title: string;
todos: Todo[];
newTodo: string;
addTodo: () => void;
updateNewTodo: (e: Event) => void;
toggleTodo: (e: Event, item: Todo, index: number) => void;
deleteTodo: (e: Event, item: Todo, index: number) => void;
completedCount: () => number;
}
const state: TodoState = {
title: 'My Todo List',
todos: [],
newTodo: '',
addTodo: function() {
if (this.newTodo.trim()) {
this.todos.push({
text: this.newTodo,
completed: false
});
this.newTodo = '';
binder.update();
}
},
updateNewTodo: function(e: Event) {
this.newTodo = (e.target as HTMLInputElement).value;
},
toggleTodo: function(e: Event, item: Todo, index: number) {
this.todos[index].completed = !this.todos[index].completed;
binder.update();
},
deleteTodo: function(e: Event, item: Todo, index: number) {
this.todos.splice(index, 1);
binder.update();
},
completedCount: function() {
return this.todos.filter(t => t.completed).length;
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();new TemplateBinder(selectorOrElement: string | Element, initialState: State, transitionClass?: string)Parameters:
selectorOrElement: CSS selector string (e.g.,"#app") or HTMLElement referenceinitialState: Object containing your state and methodstransitionClass(optional): CSS class name for transition effects (default:"updated")
Examples:
// Using CSS selector
const binder = new TemplateBinder('#app', state);
// Using Element reference
const element = document.getElementById('app');
const binder = new TemplateBinder(element, state);
// With custom transition class
const binder = new TemplateBinder('#app', state, 'my-transition');
// Element with custom transition
const binder = new TemplateBinder(element, state, 'fade-effect');Enable automatic DOM updates after event handlers execute by setting the autoUpdate property:
const binder = new TemplateBinder('#app', state);
binder.autoUpdate = true; // Enable auto-update
binder.bind();When autoUpdate is enabled, you don't need to manually call update() after event handlers:
// Without auto-update (manual)
const state = {
count: 0,
increment: function() {
this.count++;
binder.update(); // Must call manually
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();
// With auto-update (automatic)
const state = {
count: 0,
increment: function() {
this.count++; // update() called automatically!
}
};
const binder = new TemplateBinder('#app', state);
binder.autoUpdate = true; // Enable auto-update
binder.bind();Async Handler Support: Auto-update also works with async handlers - it waits for Promises to resolve:
const state = {
data: null,
loadData: async function() {
this.data = await fetch('/api/data').then(r => r.json());
// update() called automatically after Promise resolves!
}
};
const binder = new TemplateBinder('#app', state);
binder.autoUpdate = true;
binder.bind();Toggle Auto-Update: You can enable or disable auto-update at any time:
binder.autoUpdate = true; // Enable
binder.autoUpdate = false; // DisableProcesses the template and performs initial rendering.
binder.bind();Updates the DOM with the current state. Call this after modifying state values (unless autoUpdate is enabled).
state.title = 'New Title';
binder.update(); // With animation (default: true)
binder.update(false); // Without animationUpdates a single state value.
binder.setState('title', 'New Title');Returns the state proxy object.
const state = binder.getState();Cleans up bindings and restores original HTML.
binder.destroy();| Directive | Description | Example |
|---|---|---|
{{ expression }} |
Text interpolation | {{ username }} |
@for="arrayName" |
Loop through array | <li @for="items">{{ item.name }}</li> |
@if="condition" |
Conditional rendering | <p @if="isVisible">Hello</p> |
@att:name="value" |
Dynamic attribute | <div @att:class="className"></div> |
@batt:name="condition" |
Boolean attribute | <input @batt:checked="isSelected" /> |
@prop:name="value" |
Element property | <data-table @prop:data="items"></data-table> |
@on:event="handler" |
Event listener | <button @on:click="handleClick">Click</button> |
Full TypeScript support with proper typing:
import { TemplateBinder } from 'template.ts';
interface AppState {
count: number;
increment: () => void;
}
const state: AppState = {
count: 0,
increment: function(this: AppState) {
this.count++;
binder.update();
}
};
const binder = new TemplateBinder('#app', state);
binder.bind();Template.Ts automatically adds a CSS class when values change, allowing you to create smooth transition effects without extra code!
Limitation: This feature available only on value update. It has no effect on list or conditional view change.
When any bound value updates, the transition class is automatically added to the element, then removed after the animation completes. You just need to define the CSS:
const binder = new TemplateBinder('#app', state, 'my-transition');.my-transition {
transition: all 0.3s ease;
transform: scale(1.1);
color: #1976d2;
}/* Fade effect */
.transition-fade {
animation: fade 0.3s ease-in-out;
}
@keyframes fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Scale pulse */
.transition-scale-pluse {
animation: pulse 0.4s ease-in-out;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
/* Slide effect */
.transition-slide {
animation: slide 0.3s ease-out;
}
@keyframes slide {
0% { transform: translateX(-10px); opacity: 0; }
100% { transform: translateX(0); opacity: 1; }
}
/* Color change */
.transition-color {
transition: color 0.5s ease;
color: #d32f2f;
}That's it! No extra JavaScript needed - just define your CSS and the transitions happen automatically when values update.
NOTE: The transition css will apply to element once .update() call.
NOTE: .update method can be called with
withAnimationparameter to control animation when needed to bypass animation. the default value is true.
You can extend Template.Ts by creating custom binders that implement the IBinder interface:
import { IBinder, BinderContext } from 'template.ts';
class MyCustomBinder implements IBinder {
readonly priority = 70; // Lower numbers execute first
private bindings: any[] = [];
// Check if this binder should handle the element
canHandle(element: Element, _context: BinderContext): boolean {
return element.hasAttribute('@custom:action');
}
// Process the element (called once during bind)
processElement(element: Element, context: BinderContext): void | 'skip-children' {
const action = element.getAttribute('@custom:action');
if (context.isStaticBinding) {
// Handle static binding (e.g., in loops)
// Evaluate immediately
} else {
// Store for dynamic updates
this.bindings.push({ element, action });
}
element.removeAttribute('@custom:action');
// Return 'skip-children' to prevent processing child elements
}
// Legacy method - not used with hierarchical walker
process(_context: BinderContext): void {}
// Update bound elements when state changes
update(context: BinderContext, _withAnimation?: boolean): void {
this.bindings.forEach(binding => {
// Update logic here
});
}
// Clean up bindings
clear(_context: BinderContext): void {
this.bindings = [];
}
}
// Register your custom binder
const binder = new TemplateBinder('#app', state);
binder.addBinder(new MyCustomBinder());
binder.bind();Priority Order:
- 10: LoopBinder (
@for) - 20: ConditionalBinder (
@if) - 30: TextBinder (
{{ }}) - 40: AttributeBinder (
@att:,@batt:) - 50: PropertyBinder (
@prop:) - 60: EventBinder (
@on:) - 70+: Your custom binders
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Modern browsers with ES2020+ support
Try it online: https://xoboto.github.io/template.ts/
Check out the /examples folder for more complete examples:
npm run exampleThis will start a local server and open the examples in your browser.
Contributions are welcome! Please feel free to submit a Pull Request.
MIT License - see the LICENSE file for details.
Xoboto Contributors
Made with β€οΈ using TypeScript