Bindings
Bindings in Ripple provide a declarative way to synchronize DOM element properties with reactive state. Instead of manually handling events and updates, bindings create a two-way connection between your tracked variables and DOM elements.
All binding functions require a Tracked object as their argument. If
you pass a non-tracked value, they will throw a TypeError.
Form Bindings
bindValue
The bindValue binding creates a two-way connection between a tracked variable and an input or select element's value.
For text inputs:
import { bindValue, track } from 'ripple';
export function App() @{
let &[name, nameTracked] = track('');
<div>
<input
type="text"
ref={bindValue(nameTracked)}
placeholder="Enter your name"
/>
<p>Hello, {name || 'stranger'}!</p>
<button onClick={() => (name = '')}>Clear</button>
</div>
}For number inputs:
import { bindValue, track } from 'ripple';
export function App() @{
let &[age, ageTracked] = track(0);
<div>
<input type="number" ref={bindValue(ageTracked)} min="0" max="120" />
<p>Age: {age} years old</p>
<button onClick={() => (age = age + 1)}>Increment</button>
</div>
}For select elements:
import { bindValue, track } from 'ripple';
export function App() @{
let &[selectedFruit, selectedFruitTracked] = track('apple');
<div>
<select ref={bindValue(selectedFruitTracked)}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
<option value="durian">Durian</option>
</select>
<p>You selected: {selectedFruit}</p>
</div>
}For multiple select:
import { bindValue, track } from 'ripple';
export function App() @{
let &[selectedColors, selectedColorsTracked] = track(['red', 'blue']);
<div>
<select multiple ref={bindValue(selectedColorsTracked)} style="height: 100px">
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="yellow">Yellow</option>
</select>
<p>Selected colors: {selectedColors.join(', ')}</p>
</div>
}bindChecked
The bindChecked binding synchronizes a checkbox's checked state with a tracked boolean value.
import { bindChecked, track } from 'ripple';
export function App() @{
let &[agreed, agreedTracked] = track(false);
<div>
<label>
<input type="checkbox" ref={bindChecked(agreedTracked)} />
I agree to the terms and conditions
</label>
<p>Status: {agreed ? 'Agreed' : 'Not agreed'}</p>
<button disabled={!agreed}>Submit</button>
</div>
}Note
bindCheckedonly supports individual checkbox boolean binding. For checkbox groups or radio buttons, usebindGroupinstead.For
radioinputs, usebindGroupinstead ofbindChecked.
bindIndeterminate
The bindIndeterminate binding synchronizes a checkbox's indeterminate state with a tracked boolean value. The indeterminate state is commonly used for "select all" checkboxes when only some (but not all) child items are selected.
import { bindChecked, bindIndeterminate, track } from 'ripple';
export function App() @{
let &[checked, checkedTracked] = track(false);
let &[indeterminate, indeterminateTracked] = track(true);
<div>
<label>
<input
type="checkbox"
ref={[bindChecked(checkedTracked), bindIndeterminate(indeterminateTracked)]}
/>
Select All
</label>
<p>Checked: {checked ? 'Yes' : 'No'}</p>
<p>Indeterminate: {indeterminate ? 'Yes' : 'No'}</p>
<button
onClick={() => {
indeterminate = !indeterminate;
if (indeterminate) {
checked = false;
}
}}
>
Toggle Indeterminate
</button>
</div>
}Note
- The indeterminate state is purely visual and doesn't affect the checkbox's checked value.
- You can combine
bindIndeterminatewithbindCheckedon the same checkbox. - Common use case: "Select All" checkboxes when some (but not all) items are selected.
bindGroup
The bindGroup binding allows you to bind a group of checkboxes to an array or a group of radio buttons to a single value. This is essential for handling multiple selections or mutually exclusive choices.
For checkbox groups (array binding):
import { bindGroup, track } from 'ripple';
export function App() @{
let &[hobbies, hobbiesTracked] = track(['reading']);
<div>
<label>
<input type="checkbox" value="reading" ref={bindGroup(hobbiesTracked)} />
Reading
</label>
<label>
<input type="checkbox" value="gaming" ref={bindGroup(hobbiesTracked)} />
Gaming
</label>
<label>
<input type="checkbox" value="sports" ref={bindGroup(hobbiesTracked)} />
Sports
</label>
<label>
<input type="checkbox" value="cooking" ref={bindGroup(hobbiesTracked)} />
Cooking
</label>
<p>Selected: {hobbies.join(', ') || 'none'}</p>
<button onClick={() => (hobbies = ['reading'])}>Reset</button>
</div>
}For radio button groups (value binding):
import { bindGroup, track } from 'ripple';
export function App() @{
let &[size, sizeTracked] = track('medium');
<div>
<label>
<input type="radio" name="size" value="small" ref={bindGroup(sizeTracked)} />
Small
</label>
<label>
<input type="radio" name="size" value="medium" ref={bindGroup(sizeTracked)} />
Medium
</label>
<label>
<input type="radio" name="size" value="large" ref={bindGroup(sizeTracked)} />
Large
</label>
<p>Selected size: {size}</p>
<button onClick={() => size = 'medium'}>Reset to "medium"</button>
</div>
}Note
- Checkboxes: The tracked value should be an array. Checked boxes add their values to the array.
- Radio buttons: The tracked value should be a single value matching one of the radio button values.
- Static values only: The
valueattribute of inputs should be static. Dynamic/reactive value attributes are not supported. If you need to change input values dynamically, you must manually update both the tracked value and the checkbox states. - Per-binding instances: Ripple's
bindGroupdoesn't require inputs to be in the same component since it uses per-binding instance groups.
bindFiles
The bindFiles binding creates a two-way connection between a tracked variable and a file input's selected files. This allows you to read selected files and programmatically update the file input.
import { bindFiles, bindNode, track } from 'ripple';
export function App() @{
let &[files, filesTracked] = track();
let &[version] = track(0);
let &[input, inputTracked] = track();
const clearFiles = () => {
files = new DataTransfer().files; // null or undefined does not work
input.value = null; // reset the input selected message
};
const createSampleFile = () => {
version++;
const dt = new DataTransfer();
const file = new File([
`Hello, World version: ${version}!`,
], `sample_${version}.txt`, {
type: 'text/plain',
});
dt.items.add(file);
for (const file of files ?? []) {
dt.items.add(file);
}
files = dt.files;
};
<div>
<input
type="file"
ref={[bindFiles(filesTracked), bindNode(inputTracked)]}
multiple
/>
<div>
@if (files && files.length > 0) {
<>
<p>Selected files:</p>
<ul>
@for (const file of Array.from(files)) {
<li>{file.name} ({file.size} bytes)</li>
}
</ul>
</>
} @else {
<p>No files selected</p>
}
</div>
<button onClick={clearFiles}>Clear files</button>
<button onClick={createSampleFile}>Add sample file</button>
</div>
}Note
FileListobjects are read-only and cannot be modified directly.- To programmatically set files, create a new
DataTransferobject and use itsfilesproperty:jsconst dt = new DataTransfer(); dt.items.add(new File(['content'], 'filename.txt')); files = dt.files; - To clear files, set the value to
new DataTransfer().files(setting tonullorundefinedwill not work for clearing). DataTransfermay not be available in server-side JS runtimes. Leave the tracked value uninitialized to prevent errors during SSR.
Dimension Bindings
bindClientWidth / bindClientHeight
These bindings track the inner dimensions of an element (excluding borders and scrollbars).
import { bindClientWidth, bindClientHeight, track } from 'ripple';
export function App() @{
let &[width, widthTracked] = track(0);
let &[height, heightTracked] = track(0);
<div
ref={[bindClientWidth(widthTracked), bindClientHeight(heightTracked)]}
style={{
resize: 'both',
overflow: 'auto',
border: '2px solid blue',
padding: '20px',
minWidth: '200px',
minHeight: '100px',
}}
>
Resize me! (drag bottom-right corner)
<p>Client Width: {width}px</p>
<p>Client Height: {height}px</p>
</div>
}bindOffsetWidth / bindOffsetHeight
These bindings track the full outer dimensions of an element (including borders).
import { bindOffsetWidth, bindOffsetHeight, track } from 'ripple';
export function App() @{
let &[width, widthTracked] = track(0);
let &[height, heightTracked] = track(0);
<>
<div
ref={[bindOffsetWidth(widthTracked), bindOffsetHeight(heightTracked)]}
style={{
border: '10px solid green',
padding: '20px',
width: '300px',
height: '150px',
}}
>
Box with borders
</div>
<p>Offset Width: {width}px (includes borders)</p>
<p>Offset Height: {height}px (includes borders)</p>
</>
}ResizeObserver Bindings
bindContentRect
Tracks the element's content rectangle from the ResizeObserver API.
import { bindContentRect, track } from 'ripple';
export function App() @{
let &[rect, rectTracked] = track({ width: 0, height: 0, top: 0, left: 0 });
<>
<div
ref={bindContentRect(rectTracked)}
style={{
resize: 'both',
overflow: 'auto',
border: '2px solid purple',
padding: '20px',
minWidth: '200px',
minHeight: '100px',
}}
>
Resize me!
</div>
<pre>{JSON.stringify(rect, null, 2)}</pre>
</>
}bindContentBoxSize
Tracks the content box size (without padding or borders).
import { bindContentBoxSize, track } from 'ripple';
export function App() @{
let &[size, sizeTracked] = track([]);
<>
<div
ref={bindContentBoxSize(sizeTracked)}
style={{
border: '5px solid orange',
padding: '15px',
width: '250px',
height: '100px',
}}
>
Content box size
</div>
<pre>
Block size: {size[0]?.blockSize || 0}px
<br />
Inline size: {size[0]?.inlineSize || 0}px
</pre>
</>
}bindBorderBoxSize
Tracks the border box size (including padding and borders).
import { bindBorderBoxSize, track } from 'ripple';
export function App() @{
let &[size, sizeTracked] = track([]);
<>
<div
ref={bindBorderBoxSize(sizeTracked)}
style={{
border: '5px solid teal',
padding: '15px',
width: '250px',
height: '100px',
}}
>
Border box size
</div>
<pre>
Block size: {size[0]?.blockSize || 0}px
<br />
Inline size: {size[0]?.inlineSize || 0}px
</pre>
</>
}bindDevicePixelContentBoxSize
Tracks the content box size in device pixels (useful for high-DPI displays).
import { bindDevicePixelContentBoxSize, track } from 'ripple';
export function App() @{
let &[size, sizeTracked] = track([]);
<>
<div
ref={bindDevicePixelContentBoxSize(sizeTracked)}
style={{
border: '3px solid crimson',
padding: '10px',
width: '200px',
height: '80px',
}}
>
Device pixel content box
</div>
<pre>
Block size: {size[0]?.blockSize || 0}px
<br />
Inline size: {size[0]?.inlineSize || 0}px
</pre>
</>
}Content Editable Bindings
bindInnerHTML
Binds to an element's innerHTML property, useful for rich text editors.
import { bindInnerHTML, track } from 'ripple';
export function App() @{
let &[content, contentTracked] = track('<strong>Bold text</strong>');
<>
<div
contentEditable={true}
ref={bindInnerHTML(contentTracked)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px',
}}
/>
<p>Raw HTML:</p>
<pre>{content}</pre>
</>
}bindInnerText
Binds to an element's innerText property (text with line breaks, no HTML).
import { bindInnerText, track } from 'ripple';
export function App() @{
let &[text, textTracked] = track('Edit me!');
<>
<div
contentEditable={true}
ref={bindInnerText(textTracked)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px'
}}
/>
<p>Text content: {text}</p>
</>
}bindTextContent
Binds to an element's textContent property (raw text, no formatting).
import { bindTextContent, track } from 'ripple';
export function App() @{
let &[text, textTracked] = track('Type here');
<>
<div
contentEditable={true}
ref={bindTextContent(textTracked)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px',
whiteSpace: 'pre-wrap'
}}
/>
<p>Text content: {text}</p>
</>
}Element Reference Binding
bindNode
A convenient way to get a reference to a DOM element.
import { bindNode, track } from 'ripple';
export function App() @{
let &[divElement, divElementTracked] = track();
const handleFocus = () => {
if (divElement) {
divElement.focus();
divElement.style.backgroundColor = 'lightblue';
}
};
<>
<div
ref={bindNode(divElementTracked)}
tabIndex={0}
style={{
border: '2px solid navy',
padding: '20px',
outline: 'none',
}}
>
Click the button to focus this div
</div>
<button onClick={handleFocus}>Focus Div</button>
</>
}Combining Multiple Bindings
You can use multiple bindings on the same element with one array-valued ref attribute:
import { bindValue, bindClientWidth, bindNode, track } from 'ripple';
export function App() @{
let &[text, textTracked] = track('');
let &[width, widthTracked] = track(0);
let &[inputElement, inputElementTracked] = track();
const logInfo = () => {
console.log('Input:', inputElement);
console.log('Value:', text);
console.log('Width:', width);
};
<div>
<input
type="text"
ref={[
bindValue(textTracked),
bindClientWidth(widthTracked),
bindNode(inputElementTracked),
]}
placeholder="Type something..."
style="width: 300px"
/>
<p>Text: {text}</p>
<p>Width: {width}px</p>
<button onClick={logInfo}>Log Info</button>
</div>
}Best Practices
Always use tracked variables: All binding functions require
Trackedobjects created withtrack().Cleanup is automatic: Bindings automatically handle cleanup when elements are removed from the DOM.
Performance: Bindings use efficient observers (ResizeObserver for dimensions) with singleton patterns to minimize overhead.
Type safety: For number inputs,
bindValueautomatically converts values to numbers.Multiple refs: Use an array-valued
refto apply several bindings to the same element.
