const model = new Model({ count : 0 });
const Counter = Component.create`
<div>
<div>Counter: ${() => model.count}</div>
<button onClick=${() => model.count++}>Increment</button>
<button onClick=${() => model.count--}>Decrement</button>
</div>
`;
Counter.mount({ model }, document.body);
Build dynamic UI components using intuitive template literals.
Simplify event handling with built-in delegation.
Keep your UI and data in sync with ease.
Render as plain text for server-side use or static builds.
Minimal overhead with efficient rendering.
Seamlessly integrates into existing Backbone.js legacy projects.
Built on modern web standards, no tooling required.
Ships with type definitions for strict typing of models, views, components, props, and events.
$ npm install rasti
import { Model, Component } from 'rasti';
import { Model, Component } from 'https://esm.run/rasti';
Include Rasti directly in your HTML using a CDN. Available UMD builds:
<script src="https://cdn.jsdelivr.net/npm/rasti"></script>
The UMD build exposes the Rasti global object:
const { Model, Component } = Rasti;
Component// Define a Timer component that displays the number of seconds from the model.
const Timer = Component.create`
<div>
Seconds: <span>${({ model }) => model.seconds}</span>
</div>
`;
// Create a model to store the seconds.
const model = new Model({ seconds : 0 });
// Mount the Timer component to the body and pass the model as an option.
Timer.mount({ model }, document.body);
// Increment the `seconds` property of the model every second.
// Only the text node inside the <span> gets updated on each render.
setInterval(() => model.seconds++, 1000);
// Define the routes for the navigation menu.
const routes = [
{ label : 'Home', href : '#' },
{ label : 'Faq', href : '#faq' },
{ label : 'Contact', href : '#contact' },
];
// Create a Link component for navigation items.
const Link = Component.create`
<a href="${({ props }) => props.href}">
${({ props }) => props.renderChildren()}
</a>
`;
// Create a Navigation component that renders Link components for each route.
const Navigation = Component.create`
<nav>
${({ props, partial }) => props.routes.map(
({ label, href }) => partial`<${Link} href="${href}">${label}</${Link}>`
)}
</nav>
`;
// Create a Main component that includes the Navigation and displays the current route's label as the title.
const Main = Component.create`
<main>
<${Navigation} routes="${({ props }) => props.routes}" />
<section>
<h1>
${({ model, props }) => props.routes.find(
({ href }) => href === (model.location || '#')
).label}
</h1>
</section>
</main>
`;
// Initialize a model to store the current location.
const model = new Model({ location : document.location.hash });
// Update the model's location state when the browser's history changes.
window.addEventListener('popstate', () => model.location = document.location.hash);
// Mount the Main component to the body, passing the routes and model as options.
Main.mount({ routes, model }, document.body);
// Create a model to store the counter value.
const model = new Model({ count : 0 });
// Create a Counter component with increment and decrement buttons.
const Counter = Component.create`
<div>
<div>Counter: ${({ model }) => model.count}</div>
<button onClick=${function() { this.model.count++; }}>Increment</button>
<button onClick=${function() { this.model.count--; }}>Decrement</button>
</div>
`;
// Mount the Counter component to the body and pass the model as an option.
Counter.mount({ model }, document.body);
// Event listeners are bound to 'this' and use delegation from the root element.
// When buttons are clicked, only the text node gets updated, not the entire component.
Rasti is built for developers who want a simple yet powerful way to create UI components without the complexity of heavy frameworks. Whether you're building a high-performance dashboard, or embedding a lightweight widget, Rasti lets you:
The fastest way to start a real-world Rasti project is create-rasti, the official scaffolding tool. It generates a ready-to-use Rasti + Vite setup with optional server-side rendering, routing, styling, and icon components.
# Interactive setup
npm create rasti
# Non-interactive single-page app
npm create rasti my-app
# Server-side rendering with routing and Tailwind CSS
npm create rasti my-app --ssr --router --tailwind
Available options include:
--ssr), or static pre-rendering (--static).--tailwind), or CSSFUN with light/dark theme support (--cssfun).--router) β A small universal router built on path-to-regexp.--icons) β Generate Rasti components from popular SVG icon sets (heroicons, akar-icons, feathericon, pixelarticons, and more).See the create-rasti repository for the full list of templates and options.
To see how Rasti's API and architecture come together in a small app, explore the sample TODO application in the example folder of the Rasti GitHub repository. It's a concise, self-contained reference for understanding how models, views, and components fit together in a simple application. Try it live here.
To scaffold a real-world project, use create-rasti.
For detailed information on how to use Rasti, refer to the API documentation.
Rasti ships with TypeScript declarations out of the box. The types are bundled in the package and resolved automatically.
Pass generics explicitly to type the resulting class:
const Header = Component.create<{ handleAddTodo: (title: string) => void }>`
<header>...</header>
`;
new Header({ handleAddTodo: (t) => console.log(t) }); // β
// With a typed model:
const App = Component.create<{}, any, AppModel>`<main>...</main>`;
App.mount({ model: new AppModel() }, document.body);
Without generics, Component.create stays permissive (parity with JS):
const Plain = Component.create`<div></div>`;
new Plain({ anything: 'goes' }); // β
When a component is used with inner content (<${Card}>...</${Card}>), rasti injects a renderChildren function into its props at runtime. Declare it in P to use it:
const Card = Component.create<{ title: string; renderChildren?: () => any }>`
<div class="card">
<h2>${({ props }) => props.title}</h2>
${({ props }) => props.renderChildren?.()}
</div>
`;
Component.extend adds the object members to the instance type. Inside its methods, this is the extended component, and lifecycle overrides get their parameters typed automatically:
const Counter = Component.create<{ initial: number }>`<div>...</div>`.extend({
onCreate() {
this.state = new Model({ count: this.props.initial }); // `this` is typed
},
onChange(model, changed) { // parameters typed automatically
if ('count' in changed) this.render();
},
increment() { this.state.count++; },
});
Counter.mount({ initial: 0 }, document.body).increment(); // β
increment is typed
To read attributes off a typed state (or model) directly, define it as a named Model subclass with declaration merging and pass it as the S (or M) generic β then there are no casts anywhere:
class ScoreState extends Model<{ points: number }> {}
interface ScoreState { points: number } // exposes this.points
class Scoreboard extends Component<{}, ScoreState> {
onCreate() {
this.state = new ScoreState({ points: 0 });
this.state.points++; // β
typed, no cast
}
}
Type the attributes with Model<YourAttrs>. Use declaration merging to surface the auto-generated getters/setters as instance properties:
import { Model } from 'rasti';
interface TodoAttrs { title: string; completed: boolean; }
class Todo extends Model<TodoAttrs> {
preinitialize() {
this.defaults = { title: '', completed: false };
}
toggle() { this.completed = !this.completed; }
}
interface Todo extends TodoAttrs {} // Exposes this.title, this.completed
const t = new Todo({ title: 'x' });
t.title.toUpperCase(); // β
t.on('change:completed', (m, value) => value && /* boolean */ console.log('done'));
import {
EventHandler,
RenderExpression,
ModelAttrs,
ComponentProps,
ComponentState,
ComponentModel,
} from 'rasti';
// `Counter` (defined above with Component.create) is a *value*. To use the name in
// type position, alias it once β now `Counter` is both a value and a type:
type Counter = InstanceType<typeof Counter>;
// Typed event handler with `this` bound to the component
const onClick: EventHandler<Counter, MouseEvent> = function(ev) {
this.props.initial;
};
// Typed render expression (`(component) => any`)
const renderLabel: RenderExpression<Counter> = ({ props }) => props.initial;
// Extract types from existing classes
type A = ModelAttrs<Todo>; // Todo extends Model β already a type, no alias
type P = ComponentProps<Counter>; // pass the instance; `ComponentProps<typeof Counter>` is `never`
type S = ComponentState<Counter>;
Components made with
Component.createare values, not types. To use one as a type β as withCounterabove β addtype X = InstanceType<typeof X>next to the definition, or writeInstanceType<typeof X>inline. AModelsubclass needs no alias, sinceclassalready declares both a value and a type.
Functions inside a template are any β rasti can't infer them from the surrounding string. Which type to use depends on how rasti treats the function (quoted attribute or content β run on render; unquoted attribute β passed as-is):
Under
strict/noImplicitAny, every interpolation callback must be annotated β an untyped parameter is an error (TS7031/TS7006), not a silentany. In non-strict mode typing is opt-in: annotate where you want safety and leave trivial ones asany.
| Interpolation | What it is | Type to use |
|---|---|---|
Content ${fn} or quoted attr attr="${fn}" |
Run on render; this and the argument are the component |
RenderExpression<C> |
Unquoted onX=${fn} |
DOM handler, called (event, component, matched) |
EventHandler<C, E> |
Function passed to a child (handler=${fn}) |
Becomes the child's prop; typed by the child, not this component | the child's prop signature |
Three ways to apply them:
// `Home` is a value (made with Component.create), so alias it to use the name as a type:
const Home = Component.create<{}, { location: string }>`<div></div>`.extend({
close() { /* ... */ },
});
type Home = InstanceType<typeof Home>;
// 1. Named const β cleanest for non-trivial handlers
const onClick: EventHandler<Home, MouseEvent> = function(ev, self) {
ev.preventDefault();
self.close();
};
// 2. Inline with `satisfies` β checks + types the params without widening
${(({ state }) => state?.location) satisfies RenderExpression<Home>}
// 3. Bare annotation β lightest, just types the argument
${({ state }: Home) => state?.location}
For a function passed to a child, neither helper fits β its type comes from the child's prop. Type it against that prop's declared type (rasti can't connect the attribute to the child, since both live inside the template string):
// where the child was created with Component.create<ToggleAllProps>`...`
handleChange=${((checked) => model.toggleAll(checked)) satisfies ToggleAllProps['handleChange']}
any. Functions in Component.createβ¦`` templates can't be inferred from the surrounding string β type them opt-in (see Typing template interpolations).Model<A> instance keys require declaration merging. TypeScript can't add A's keys to a class extends Model<A> automatically β see the interface Todo extends TodoAttrs {} pattern above.this.$() can return null. It mirrors querySelector, so handle the empty case (?.) and pass a type argument to narrow the element: this.lt;HTMLInputElement>('input.edit')?.focus(). this.$() returns a NodeListOf<HTMLElement> (also narrowable).this.model / this.state are optional. Both are undefined unless provided, so guard (this.model?.foo) or assert (this.model!) when you know one was passed. Both accept a Rasti Model or a model from another library (e.g. Backbone); Components subscribe to change events automatically when the object exposes on/off.state / model are raw generics, props is not. this.props is always a Model built by rasti, so it's typed Model<P> & P (direct access to P's keys). But state and model can be anything you provide β a Rasti Model, a Backbone model, a store, or a plain object β so they stay the raw generic. To read a typed Model state/model directly, define it as a named subclass with declaration merging and pass it as the S/M generic (see the Scoreboard example above) β no casts needed.P has no required keys and you pass only options not declared in it, TypeScript reports "has no properties in common" (weak-type check). Fix: declare those options in P β non-reserved options become props at runtime..extend hooks need predeclaration. .extend infers the instance type from the object's members only, so a field first assigned in onCreate (this.router = ...) isn't known. Predeclare it in the object: router: null as unknown as Router. For components with many instance fields, class MyComponent extends Component<P, S> is usually cleaner than .extend.For those working with LLMs, there is an AI Agents reference guide that provides API patterns, lifecycle methods, and best practices, optimized for LLM context. You can share this guide with AI assistants to help them understand Rasti's architecture and component APIs.
Release history and migration notes for major versions are in CHANGELOG.md.