Ripple is a TypeScript-first UI framework built around .tsrx files, fine-grained
reactivity, scoped styles, and a small runtime. It pairs the authoring feel of JSX
with template-native control flow and TypeScript setup that can live right beside
the UI it feeds.
Created by @trueadm, who has contributed to Inferno, React, Lexical, and Svelte 5.
.tsrxis also a standalone language. The shared TSRX compiler stack can target React, Preact, Solid, Vue, and Ripple. Ripple is the runtime-focused target withtrack(), reactive collections, server modules, hydration, and DOM helpers.
Ripple Docs | Ripple Playground | TSRX Website
- Fine-grained reactivity with
track()and lazy destructuring. - Reactive
RippleArray,RippleObject,RippleMap, andRippleSet. - Template-native
@if,@for,@switch, and@try. - Local TypeScript setup with JSX statement containers (
@{...}). - Scoped
<style>blocks with automatic class hashing. - Vite, editor, Prettier, ESLint, SSR, and hydration support.
npx create-ripple
cd my-app
npm install
npm run devnpx degit Ripple-TS/ripple/templates/basic my-app
cd my-app
npm install
npm run devnpm install ripple @ripple-ts/vite-pluginUse npm, pnpm, yarn, or bun, matching your project.
// index.ts
import { mount } from 'ripple';
import { App } from './App.tsrx';
mount(App, {
props: { title: 'Hello world!' },
target: document.getElementById('root'),
});Components are ordinary TypeScript functions. Return a JSX element directly when
the component has one root, and use a JSX statement container (@{...}) when
setup statements or multiple rendered siblings belong next to the UI.
type ButtonProps = {
text: string;
onClick: () => void;
};
export function Button({ text, onClick }: ButtonProps) {
return <button class="button" {onClick}>{text}</button>;
}
export function App() {
return <Button text="Click me" onClick={() => console.log('Clicked!')} />;
}
Fragments are still useful when the component really returns multiple siblings,
such as markup plus a scoped <style> block.
Plain JSX children are text, elements, comments, and {...} expression
containers. When a scope needs TypeScript setup before rendering, use a JSX
statement container: @{...}. Setup comes first and the container finishes with
exactly one output node: a JSX element, JSX fragment, or JSX control-flow
expression. If the output needs text, expression containers, or multiple siblings
after setup, wrap them in a fragment.
Text such as x = 123 between tags is JSX text, not JavaScript, unless it is
inside a statement container.
import { track } from 'ripple';
export function Counter() @{
let &[count] = track(0);
const increment = () => count++;
<button onClick={increment}>Count:{count}</button>
}
The same rule applies in nested scopes:
export function Cart({ items }: { items: Item[] }) @{
<div class="cart">@{
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const discount =
subtotal > 100 ? 0.1 : 0;
<>
<p>Subtotal: ${subtotal}</p>
<p>Save: ${(subtotal * discount).toFixed(2)}</p>
</>
}</div>
}
JavaScript comments are allowed between template children and are not rendered.
Static text is JSX text. Dynamic values use normal JSX expression containers.
export function Greeting({ name }: { name?: string }) @{
@if (name) {
<p>Hello,{name}</p>
} @else {
<p>Hello, stranger</p>
}
}
Rendered control flow uses directive-prefixed expressions:
import { RippleArray, track } from 'ripple';
type Item = { id: number; name: string; done?: boolean };
export function TodoList() @{
const items = new RippleArray<Item>({ id: 1, name: 'Plan the work' }, {
id: 2,
name: 'Ship the work',
});
let &[showDone] = track(true);
const visibleItems = () => items.filter((item) => showDone || !item.done);
<ul>
@for (const item of visibleItems(); index i; key item.id) {
<li>
{i + 1}
.
{item.name}
</li>
} @empty {
<li>No todos to show</li>
}
</ul>
}
Use ordinary return for real function exits in TypeScript setup. Use @if for
conditional rendering; direct return, continue, and break statements are not
valid inside @if template branches.
export function Dashboard({ user }: { user: User | null }) @{
if (!user) {
return null;
}
<>
<h1>Welcome,{user.name}</h1>
<p>Here is your dashboard.</p>
</>
}
@try supports error and pending UI:
export function ProfilePanel() @{
@try {
<UserProfile />
} @pending {
<p>Loading...</p>
} @catch (error, reset) {
<div>
<p>Error:{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
}
}
Create state with track() and lazy destructuring. Reads of lazy bindings stay
reactive, and assignments write back to the tracked value.
import { effect, track, type Tracked } from 'ripple';
export function Counter() @{
let &[count, trackedCount] = track(0);
let &[double] = track(() => count * 2);
effect(() => {
console.log('Count changed:', count);
});
<>
<p>Count:{count}</p>
<p>Double:{double}</p>
<button onClick={() => count++}>Increment</button>
<CounterValue count={trackedCount} />
</>
}
function CounterValue({ count }: { count: Tracked<number> }) {
return <p>Shared value:{count.value}</p>;
}
Tracked<T> objects can also be read and written through .value, which is
useful when passing reactive values through data structures or props.
Use Ripple collections when collection operations should be reactive.
import { RippleArray, RippleMap, RippleObject, RippleSet } from 'ripple';
export function Inventory() @{
const items = new RippleArray({ id: 1, name: 'Jacket' });
const totals = new RippleObject({ selected: 0 });
const prices = new RippleMap([[1, 120]]);
const selected = new RippleSet<number>();
<>
<ul>
@for (const item of items; key item.id) {
<li>{item.name}: ${prices.get(item.id)}</li>
}
</ul>
<button onClick={() => selected.add(1)}>Select first item</button>
<p>
Selected:
{selected.size + totals.selected}
</p>
</>
}
DOM refs use ref, and events use JSX-style event props.
import { track } from 'ripple';
export function SearchBox() @{
let &[value] = track('');
let input: HTMLInputElement | undefined;
<>
<label>
Search
<input
ref={input}
value={value}
onInput={(event) => {
value = event.currentTarget.value;
}}
/>
</label>
<button onClick={() => input?.focus()}>Focus</button>
</>
}
<style> blocks are static CSS and are scoped to the template. Use CSS custom
properties for runtime values.
import { track } from 'ripple';
export function Notice() @{
let &[tone] = track('rebeccapurple');
<>
<p class="notice" style={{ '--notice-color': tone }}>Scoped text</p>
<button
onClick={() => (tone = tone === 'rebeccapurple'
? 'tomato'
: 'rebeccapurple')}
>Toggle tone</button>
<style>
.notice {
color: var(--notice-color);
font-weight: 700;
}
</style>
</>
}
Module-scope style expressions can expose scoped class names:
const styles = <style>
.highlight {
background: #e8f5e9;
}
</style>;
export function Badge() {
return <span class={styles.highlight}>New</span>;
}
import { Context, Portal, track, type Tracked } from 'ripple';
const ThemeContext = new Context<Tracked<string>>();
export function App() @{
let &[theme, themeTracked] = track('light');
ThemeContext.set(themeTracked);
<>
<ThemeLabel />
<button onClick={() => (theme = theme === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
<Portal target={document.body}>
<p>Portal content</p>
</Portal>
</>
}
function ThemeLabel() @{
const theme = ThemeContext.get();
<p>Theme:{theme.value}</p>
}
Ripple supports module server in .tsrx files for server-oriented exports.
Import from server inside the same file before calling the server function.
module server {
export async function loadMessage() {
return 'Loaded on the server';
}
}
import { loadMessage } from server;
import { effect, track } from 'ripple';
export function Page() @{
let &[message] = track('Loading...');
effect(() => {
loadMessage().then((next) => {
message = next;
});
});
<p>{message}</p>
}
Install the Ripple VSCode extension for syntax highlighting, diagnostics, TypeScript integration, and completions.
Contributions are welcome. Please see CONTRIBUTING.md.
MIT License - see LICENSE for details.