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() @{
const name = track('');
<div>
<input
type="text"
ref={bindValue(name)}
placeholder="Enter your name"
/>
<p>Hello, {name.value || 'stranger'}!</p>
<button onClick={() => (name.value = '')}>Clear</button>
</div>
}For number inputs:
import { bindValue, track } from 'ripple';
export function App() @{
const age = track(0);
<div>
<input type="number" ref={bindValue(age)} min="0" max="120" />
<p>Age: {age.value} years old</p>
<button onClick={() => (age.value = age.value + 1)}>Increment</button>
</div>
}For select elements:
import { bindValue, track } from 'ripple';
export function App() @{
const selectedFruit = track('apple');
<div>
<select ref={bindValue(selectedFruit)}>
<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.value}</p>
</div>
}For multiple select:
import { bindValue, track } from 'ripple';
export function App() @{
const selectedColors = track(['red', 'blue']);
<div>
<select multiple ref={bindValue(selectedColors)} 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.value.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() @{
const agreed = track(false);
<div>
<label>
<input type="checkbox" ref={bindChecked(agreed)} />
I agree to the terms and conditions
</label>
<p>Status: {agreed.value ? 'Agreed' : 'Not agreed'}</p>
<button disabled={!agreed.value}>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() @{
const checked = track(false);
const indeterminate = track(true);
<div>
<label>
<input
type="checkbox"
ref={[bindChecked(checked), bindIndeterminate(indeterminate)]}
/>
Select All
</label>
<p>Checked: {checked.value ? 'Yes' : 'No'}</p>
<p>Indeterminate: {indeterminate.value ? 'Yes' : 'No'}</p>
<button
onClick={() => {
indeterminate.value = !indeterminate.value;
if (indeterminate.value) {
checked.value = 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() @{
const hobbies = track(['reading']);
<div>
<label>
<input type="checkbox" value="reading" ref={bindGroup(hobbies)} />
Reading
</label>
<label>
<input type="checkbox" value="gaming" ref={bindGroup(hobbies)} />
Gaming
</label>
<label>
<input type="checkbox" value="sports" ref={bindGroup(hobbies)} />
Sports
</label>
<label>
<input type="checkbox" value="cooking" ref={bindGroup(hobbies)} />
Cooking
</label>
<p>Selected: {hobbies.value.join(', ') || 'none'}</p>
<button onClick={() => (hobbies.value = ['reading'])}>Reset</button>
</div>
}For radio button groups (value binding):
import { bindGroup, track } from 'ripple';
export function App() @{
const size = track('medium');
<div>
<label>
<input type="radio" name="size" value="small" ref={bindGroup(size)} />
Small
</label>
<label>
<input type="radio" name="size" value="medium" ref={bindGroup(size)} />
Medium
</label>
<label>
<input type="radio" name="size" value="large" ref={bindGroup(size)} />
Large
</label>
<p>Selected size: {size.value}</p>
<button onClick={() => size.value = '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() @{
const files = track();
const version = track(0);
const input = track();
const clearFiles = () => {
files.value = new DataTransfer().files; // null or undefined does not work
input.value.value = null; // reset the input selected message
};
const createSampleFile = () => {
version.value++;
const dt = new DataTransfer();
const file = new File([
`Hello, World version: ${version.value}!`,
], `sample_${version.value}.txt`, {
type: 'text/plain',
});
dt.items.add(file);
for (const file of files.value ?? []) {
dt.items.add(file);
}
files.value = dt.files;
};
<div>
<input
type="file"
ref={[bindFiles(files), bindNode(input)]}
multiple
/>
<div>
@if (files.value && files.value.length > 0) {
<>
<p>Selected files:</p>
<ul>
@for (const file of Array.from(files.value)) {
<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.value = 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() @{
const width = track(0);
const height = track(0);
<div
ref={[bindClientWidth(width), bindClientHeight(height)]}
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.value}px</p>
<p>Client Height: {height.value}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() @{
const width = track(0);
const height = track(0);
<>
<div
ref={[bindOffsetWidth(width), bindOffsetHeight(height)]}
style={{
border: '10px solid green',
padding: '20px',
width: '300px',
height: '150px',
}}
>
Box with borders
</div>
<p>Offset Width: {width.value}px (includes borders)</p>
<p>Offset Height: {height.value}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() @{
const rect = track({ width: 0, height: 0, top: 0, left: 0 });
<>
<div
ref={bindContentRect(rect)}
style={{
resize: 'both',
overflow: 'auto',
border: '2px solid purple',
padding: '20px',
minWidth: '200px',
minHeight: '100px',
}}
>
Resize me!
</div>
<pre>{JSON.stringify(rect.value, null, 2)}</pre>
</>
}bindContentBoxSize
Tracks the content box size (without padding or borders).
import { bindContentBoxSize, track } from 'ripple';
export function App() @{
const size = track([]);
<>
<div
ref={bindContentBoxSize(size)}
style={{
border: '5px solid orange',
padding: '15px',
width: '250px',
height: '100px',
}}
>
Content box size
</div>
<pre>
Block size: {size.value[0]?.blockSize || 0}px
<br />
Inline size: {size.value[0]?.inlineSize || 0}px
</pre>
</>
}bindBorderBoxSize
Tracks the border box size (including padding and borders).
import { bindBorderBoxSize, track } from 'ripple';
export function App() @{
const size = track([]);
<>
<div
ref={bindBorderBoxSize(size)}
style={{
border: '5px solid teal',
padding: '15px',
width: '250px',
height: '100px',
}}
>
Border box size
</div>
<pre>
Block size: {size.value[0]?.blockSize || 0}px
<br />
Inline size: {size.value[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() @{
const size = track([]);
<>
<div
ref={bindDevicePixelContentBoxSize(size)}
style={{
border: '3px solid crimson',
padding: '10px',
width: '200px',
height: '80px',
}}
>
Device pixel content box
</div>
<pre>
Block size: {size.value[0]?.blockSize || 0}px
<br />
Inline size: {size.value[0]?.inlineSize || 0}px
</pre>
</>
}Content Editable Bindings
During hydration, these bindings adopt edits made before the client starts, including cleared content. The edited DOM subtree and its text selection are preserved instead of hydrating its original template children. Untouched editors initialize from the bound model as usual; subsequent input and model updates stay synchronized.
bindInnerHTML
Binds to an element's innerHTML property, useful for rich text editors.
import { bindInnerHTML, track } from 'ripple';
export function App() @{
const content = track('<strong>Bold text</strong>');
<>
<div
contentEditable={true}
ref={bindInnerHTML(content)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px',
}}
/>
<p>Raw HTML:</p>
<pre>{content.value}</pre>
</>
}bindInnerText
Binds to an element's innerText property (text with line breaks, no HTML).
import { bindInnerText, track } from 'ripple';
export function App() @{
const text = track('Edit me!');
<>
<div
contentEditable={true}
ref={bindInnerText(text)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px'
}}
/>
<p>Text content: {text.value}</p>
</>
}bindTextContent
Binds to an element's textContent property (raw text, no formatting).
import { bindTextContent, track } from 'ripple';
export function App() @{
const text = track('Type here');
<>
<div
contentEditable={true}
ref={bindTextContent(text)}
style={{
border: '1px solid gray',
padding: '10px',
minHeight: '50px',
whiteSpace: 'pre-wrap'
}}
/>
<p>Text content: {text.value}</p>
</>
}Element Reference Binding
bindNode
A convenient way to get a reference to a DOM element.
import { bindNode, track } from 'ripple';
export function App() @{
const divElement = track();
const handleFocus = () => {
if (divElement.value) {
divElement.value.focus();
divElement.value.style.backgroundColor = 'lightblue';
}
};
<>
<div
ref={bindNode(divElement)}
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() @{
const text = track('');
const width = track(0);
const inputElement = track();
const logInfo = () => {
console.log('Input:', inputElement.value);
console.log('Value:', text.value);
console.log('Width:', width.value);
};
<div>
<input
type="text"
ref={[
bindValue(text),
bindClientWidth(width),
bindNode(inputElement),
]}
placeholder="Type something..."
style="width: 300px"
/>
<p>Text: {text.value}</p>
<p>Width: {width.value}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.
