github

trueadm / ripple

  • суббота, 13 сентября 2025 г. в 00:00:02
https://github.com/trueadm/ripple

the elegant TypeScript UI framework



Ripple - the elegant TypeScript UI framework

CI Discord Open in StackBlitz

What is Ripple?

Currently, this project is still in early development, and should not be used in production.

Ripple is a TypeScript UI framework that takes the best parts of React, Solid and Svelte and combines them into one package.

I wrote Ripple as a love letter for frontend web – and this is largely a project that I built in less than a week, so it's very raw.

Personally, I (@trueadm) have been involved in some truly amazing frontend frameworks along their journeys – from Inferno, where it all began, to React and the journey of React Hooks, to creating Lexical, to Svelte 5 and its new compiler and signal-based reactivity runtime. Along that journey, I collected ideas, and intriguing thoughts that may or may not pay off. Given my time between roles, I decided it was the best opportunity to try them out, and for open source to see what I was cooking.

Ripple was designed to be a JS/TS-first framework, rather than HTML-first. Ripple modules have their own .ripple extension and these modules fully support TypeScript. By introducing a new extension, it affords Ripple to invent its own superset language, that plays really nicely with TypeScript and JSX, but with a few interesting touches. In my experience, this has led to better DX not only for humans, but also for LLMs.

Right now, there will be plenty of bugs, things just won't work either and you'll find TODOs everywhere. At this stage, Ripple is more of an early alpha version of something that might be, rather than something you should try and adopt. If anything, maybe some of the ideas can be shared and incubated back into other frameworks. There's also a lot of similarities with Svelte 5, and that's not by accident, that's because of my recent time working on Svelte 5.

If you'd like to know more, join the Ripple Discord.

Features

  • Reactive State Management: Built-in reactivity with $ prefixed variables and object properties
  • Component-Based Architecture: Clean, reusable components with props and children
  • JSX-like Syntax: Familiar templating with Ripple-specific enhancements
  • Performance: Fine-grain rendering, with industry leading performance and memory usage
  • TypeScript Support: Full TypeScript integration with type checking
  • VSCode Integration: Rich editor support with diagnostics, syntax highlighting, and IntelliSense
  • Prettier Support: Full Prettier formatting support for .ripple modules

Missing Features

  • SSR: Ripple is currently an SPA only, this is because I haven't gotten around to it
  • Types: The codebase is very raw with limited types, we're getting around to it

Getting Started

Try Ripple

We're working hard on getting an online playground available. Watch this space!

You can try Ripple now by using our basic Vite template either via StackBlitz, or by running these commands in your terminal:

npx degit trueadm/ripple/templates/basic my-app
cd my-app
npm i # or yarn or pnpm
npm run dev # or yarn or pnpm

VSCode Extension

The Ripple VSCode extension provides:

  • Syntax Highlighting for .ripple files
  • Real-time Diagnostics for compilation errors
  • TypeScript Integration for type checking
  • IntelliSense for autocompletion

You can find the extension on the VS Code Marketplace as Ripple for VS Code.

You can also manually install the extension .vsix that have been manually packaged.

Mounting your app

You can use the mount API from the ripple package to render your Ripple component, using the target option to specify what DOM element you want to render the component.

// index.ts
import { mount } from 'ripple';
import { App } from '/App.ripple';

mount(App, {
  props: {
    title: 'Hello world!',
  },
  target: document.getElementById('root'),
});

Key Concepts

Components

Define reusable components with the component keyword. These are similar to functions in that they have props, but crucially, they allow for a JSX-like syntax to be defined alongside standard TypeScript. That means you do not return JSX like in other frameworks, but you instead use it like a JavaScript statement, as shown:

component Button(props: { text: string, onClick: () => void }) {
  <button onClick={props.onClick}>
    {props.text}
  </button>
}

// Usage
export component App() {
  <Button text="Click me" onClick={() => console.log("Clicked!")} />
}

Ripple's templating language also supports shorthands and object spreads too:

// you can do a normal prop
<div onClick={onClick}>{text}</div>

// or using the shorthand prop
<div {onClick}>{text}</div>

// and you can spread props
<div {...properties}>{text}</div>

Reactive Variables

Variables prefixed with $ are automatically reactive:

let $name = 'World';
let $count = 0;

// Updates automatically trigger re-renders
$count++;

Object properties prefixed with $ are also automatically reactive:

let counter = { $current: 0 };

// Updates automatically trigger re-renders
counter.$current++;

Derived values are simply $ variables that combined different parts of state:

let $count = 0;
let $double = $count * 2;
let $quadruple = $double * 2;

That means $count itself might be derived if it were to reference another reactive property. For example:

component Counter({ $startingCount }) {
  let $count = $startingCount;
  let $double = $count * 2;
  let $quadruple = $double * 2;
}

Now given $startingCount is reactive, it would mean that $count might reset each time an incoming change to $startingCount occurs. That might not be desirable, so Ripple provides a way to untrack reactivity in those cases:

import { untrack } from 'ripple';

component Counter({ $startingCount }) {
  let $count = untrack(() => $startingCount);
  let $double = $count * 2;
  let $quadruple = $double * 2;
}

Now $count will only reactively create its value on initialization.

Note: you cannot define reactive variables in module/global scope, they have to be created on access from an active component

Transporting Reactivity

Ripple doesn't constrain reactivity to components only. Reactivity can be used inside other functions (and classes in the future) and be composed in a way to improve expressitivity and co-location.

Ripple provides a very nice way to transport reactivity between boundaries so that it's persisted – using objects and arrays. Here's an example using arrays to transport reactivity:

import { effect } from 'ripple';

function createDouble([ $count ]) {
  const $double = $count * 2;

  effect(() => {
    console.log('Count:', $count)
  });

  return [ $double ];
}

export component App() {
  let $count = 0;

  const [ $double ] = createDouble([ $count ]);

  <div>{'Double: ' + $double}</div>
  <button onClick={() => { $count++; }}>{'Increment'}</button>
}

You can do the same with objects too:

import { effect } from 'ripple';

function createDouble({ $count }) {
  const $double = $count * 2;

  effect(() => {
    console.log('Count:', $count)
  });

  return { $double };
}

export component App() {
  let $count = 0;
  const { $double } = createDouble({ $count });

  <div>{'Double: ' + $double}</div>
  <button onClick={() => { $count++; }}>{'Increment'}</button>
}

Just remember, reactive state must be connected to a component and it can't be global or created within the top-level of a module – because then Ripple won't be able to link it to your component tree.

Reactive Arrays

Just like, objects, you can use the $ prefix in an array literal to specify that the field is reactive.

let $first = 0;
let $second = 0;
const arr = [$first, $second];

const $total = arr.reduce((a, b) => a + b, 0);

Like shown in the above example, you can compose normal arrays with reactivity and pass them through props or boundaries.

However, if you need the entire array to be fully reactive, including when new elements get added, you should use the reactive array that Ripple provides.

You'll need to import the RippleArray class from Ripple. It extends the standard JS Array class, and supports all of its methods and properties.

import { RippleArray } from 'ripple';

// using the new constructor
const arr = new RippleArray(1, 2, 3);

// using static from method
const arr = Ripple.from([1, 2, 3]);

// using static of method
const arr = Ripple.of(1, 2, 3);

The RippleArray is a reactive array, and that means you can access properties normally using numeric index. However, accessing the length property of a RippleArray will be not be reactive, instead you should use $length.

Reactive Set

The RippleSet extends the standard JS Set class, and supports all of its methods and properties. However, accessing the size property of a RippleSet will be not be reactive, instead you should use $size.

import { RippleSet } from 'ripple';

const set = new RippleSet([1, 2, 3]);

RippleSet's reactive methods or properties can be used directly or assigned to reactive variables.

import { RippleSet } from 'ripple';

export component App() {
  const set = new RippleSet([1, 2, 3]);

  // direct usage
  <p>{"Direct usage: set contains 2: "}{set.has(2)}</p>

  // reactive assignment with prefixed `$`
  let $has = set.has(2);
  <p>{"Assigned usage: set contains 2: "}{$has}</p>

  <button onClick={() => set.delete(2)}>{"Delete 2"}</button>
  <button onClick={() => set.add(2)}>{"Add 2"}</button>
}

Reactive Map

The RippleMap extends the standard JS Map class, and supports all of its methods and properties. However, accessing the size property of a RippleMap will be not be reactive, instead you should use $size.

import { RippleMap } from 'ripple';

const map = new RippleMap([[1,1], [2,2], [3,3], [4,4]]);

RippleMap's reactive methods or properties can be used directly or assigned to reactive variables.

import { RippleMap } from 'ripple';

export component App() {
  const map = new RippleMap([[1,1], [2,2], [3,3], [4,4]]);

  // direct usage
  <p>{"Direct usage: map has an item with key 2: "}{map.has(2)}</p>

  // reactive assignment with prefixed `$`
  let $has = map.has(2);
  <p>{"Assigned usage: map has an item with key 2: "}{$has}</p>

  <button onClick={() => map.delete(2)}>{"Delete item with key 2"}</button>
  <button onClick={() => map.set(2, 2)}>{"Add key 2 with value 2"}</button>
}

Effects

When dealing with reactive state, you might want to be able to create side-effects based upon changes that happen upon updates. To do this, you can use effect:

import { effect } from 'ripple';

export component App() {
  let $count = 0;

  effect(() => {
    console.log($count);
  });

  <button onClick={() => $count++}>{'Increment'}</button>
}

Control flow

The JSX-like syntax might take some time to get used to if you're coming from another framework. For one, templating in Ripple can only occur inside a component body – you can't create JSX inside functions, or assign it to variables as an expression.

<div>
  // you can create variables inside the template!
  const str = "hello world";

  console.log(str); // and function calls too!

  debugger; // you can put breakpoints anywhere to help debugging!

  {str}
</div>

Note that strings inside the template need to be inside {"string"}, you can't do <div>hello</div> as Ripple has no idea if hello is a string or maybe some JavaScript code that needs evaluating, so just ensure you wrap them in curly braces. This shouldn't be an issue in the real-world anyway, as you'll likely use an i18n library that means using JavaScript expressions regardless.

If statements

If blocks work seamlessly with Ripple's templating language, you can put them inside the JSX-like statements, making control-flow far easier to read and reason with.

component Truthy({ x }) {
  <div>
    if (x) {
      <span>{'x is truthy'}</span>
    } else {
      <span>{'x is falsy'}</span>
    }
  </div>
}

For statements

You can render collections using a for...of block, and you don't need to specify a key prop like other frameworks.

component ListView({ title, items }) {
  <h2>{title}</h2>
  <ul>
    for (const item of items) {
      <li>{item.text}</li>
    }
  </ul>
}

You can use Ripple's reactive arrays to easily compose contents of an array.

import { RippleArray } from 'ripple';

component Numbers() {
  const items = new RippleArray(1, 2, 3);

  for (const item of items) {
    <div>{item}</div>
  }

  <button onClick={() => items.push(`Item ${items.$length + 1}`)}>{"Add Item"}</button>
}

Clicking the <button> will create a new item, note that items is not $ prefixed, because it's not reactive, but rather its properties are instead.

Try statements

Try blocks work to build the foundation for error boundaries, when the runtime encounters an error in the try block, you can easily render a fallback in the catch block.

import { reportError } from 'some-library';

component ErrorBoundary() {
  <div>
    try {
      <ComponentThatFails />
    } catch (e) {
      reportError(e);

      <div>{'An error occurred! ' + e.message}</div>
    }
  </div>
}

Props

If you want a prop to be reactive, you should also give it a $ prefix.

component Button(props: { $text: string, onClick: () => void }) {
  <button onClick={props.onClick}>
    {props.$text}
  </button>
}

// Usage
<Button $text={some_text} onClick={() => console.log("Clicked!")} />

This also applies to DOM elements, if you want an attribute or property to be reactive, it needs to have a $ prefix.

<div $class={props.$someClass} $id={$someId}>
  {$someText}
</div>

Otherwise changes to the attribute or property will not be reactively updated.

Children

Use $children prop and then use it in the form of <$children /> for component composition.

When you pass in children to a component, it gets implicitly passed as the $children prop, in the form of a component.

import type { Component } from 'ripple';

component Card(props: { $children: Component }) {
  <div class="card">
    <props.$children />
  </div>
}

// Usage
<Card>
  <p>{"Card content here"}</p>
</Card>

You could also explicitly write the same code as shown:

import type { Component } from 'ripple';

component Card(props: { $children: Component }) {
  <div class="card">
    <props.$children />
  </div>
}

// Usage with explicit component
<Card>
  component $children() {
    <p>{"Card content here"}</p>
  }
</Card>

Accessor Props

When working with props on composite components (<Foo> rather than <div>), it can sometimes be difficult to debug why a certain value is a certain way. JavaScript gives us a way to do this on objects using the get syntax:

let name = 'Bob';

const object = {
  get name() {
    // I can easily debug when this property gets
    // access and track it easily
    console.log(name);
    return name;
  }
}

So Ripple provides similar capabilities when working with composite components in a template, specifcally using $prop:={} rather than the typical $prop={}.

In fact, when you use an accessor, you must pass a function, and the prop must be $ prefixed, as Ripple considers accessor props as reactive:

let $name = 'Bob';

const getName = () => {
  // I can easily debug when this property gets
  // access and track it easily
  console.log(name);
  return $name;
};

<Person $name:={getName} />

You can also inline the function too:

let $name = 'Bob';

<Person $name:={() => {
  // I can easily debug when this property gets
  // access and track it easily
  console.log(name);
  return $name;
}} />

Furthermore, just like property accessors in JavaScript, Ripple provides a way of capturing the set too, enabling two-way data-flow on composite component props. You just need to provide a second function after the first, separated using a comma:

let $name = 'Bob';

const getName = () => {
  return $name;
}

const setName = (newName) => {
  $name = newName;
}

<Person $name:={getName, setName} />

Or an inlined version:

let $name = 'Bob';

<Person $name:={() => $name, (newName) => $name = $newName} />

Now changes in the Person to its props will propagate to its parent component:

component Person(props) {
  const updateName = (newName) => {
    props.$name = newName;
  }

  <NameInput onChange={updateName}>
}

Decorators

Ripple provides a consistent way to capture the underlying DOM element – decorators. Specifically, using the syntax {@use fn} where fn is a function that captures the DOM element. If you're familiar with other frameworks, then this is identical to {@attach fn} in Svelte 5 and somewhat similar to ref in React. The hook function will receive the reference to the underlying DOM element.

export component App() {
  let $node;

  const ref = (node) => {
    $node = node;
    console.log("mounted", node);

    return () => {
      $node = undefined;
      console.log("unmounted", node);
    };
  };

  <div {@use ref}>{"Hello world"}</div>
}

You can also create {@use} functions inline.

export component App() {
  let $node;

  <div {@use (node) => {
    $node = node;
    return () => $node = null;
  }}>{"Hello world"}</div>
}

You can also use function factories to define properties, these are functions that return functions that do the same thing. However, you can use this pattern to pass reactive properties.

import { fadeIn } from 'some-library';

export component App({ $ms }) {
  <div {@use fadeIn({ $ms })}>{"Hello world"}</div>
}

Lastly, you can use decorators on composite components.

<Image {@use (node) => console.log(node)} {...props} />

When passing decorators to composite components (rather than HTML elements) as shown above, they will be passed a Symbol property, as they are not named. This still means that it can be spread to HTML template elements later on, and still work.

Event Props

Like React, events are props that start with on and then continue with an uppercase character, such as:

  • onClick
  • onPointerMove
  • onPointerDown
  • onKeyDown

For capture phase events, just add Capture to the end of the prop name:

  • onClickCapture
  • onPointerMoveCapture
  • onPointerDownCapture
  • onKeyDownCapture

Note: Some events are automatically delegated where possible by Ripple to improve runtime performance.

Styling

Ripple supports native CSS styling that is localized to the given component using the <style> element.

component MyComponent() {
  <div class="container"><h1>{'Hello World'}</h1></div>

  <style>
    .container {
      background: blue;
      padding: 1rem;
    }

    h1 {
      color: white;
      font-size: 2rem;
    }
  </style>
}

Note: the <style> element must be top-level within a component.

Context

Ripple has the concept of context where a value or reactive object can be shared through the component tree – like in other frameworks. This all happens from the createContext function that is imported from ripple.

When you create a context, you can get and set the values, but this must happen within the component. Using them outside will result in an error being thrown.

import { createContext } from 'ripple';

const MyContext = createContext(null);

component Child() {
  // Context is read in the Child component
  const value = MyContext.get(MyContext);

  // value is "Hello from context!"
  console.log(value);
}

component Parent() {
  const value = MyContext.get(MyContext);

  // Context is read in the Parent component, but hasn't yet
  // been set, so we fallback to the initial context value.
  // So the value is `null`
  console.log(value);

  // Context is set in the Parent component
  MyContext.set("Hello from context!");

  <Child />
}

Playground

Feel free to play around with how Ripple works. If you clone the repo, you can then:

pnpm i && cd playground && pnpm dev

The playground uses Ripple's Vite plugin, where you can play around with things inside the playground/src directory.

Contributing

We are happy for your interest in contributing. Please see our contributing guidelines for more information.

License

See the MIT license.