Renderers
How to take full control over the HTML and layout of your forms.
Rilaykit is headless: every pixel comes from a renderer you own. Rendering has two layers — catalog renderers draw registered components, tools, and parts; structural components provide overridable layout (body, submit, flow navigation).
Catalog renderers
Attach a renderer when registering an entry, or later with .renderers(). Both are immutable and return a new instance.
import { ril } from 'rilaykit';
export const catalog = ril.create().component('text', {
name: 'Text Input',
renderer: ({ id, props, field }) => (
<div>
<input
id={id}
value={(field?.value as string) ?? ''}
onChange={(e) => field?.onChange(e.target.value)}
onBlur={field?.onBlur}
disabled={field?.disabled}
aria-invalid={!!field?.error?.length}
{...props}
/>
{field?.touched &&
field?.error?.map((e) => (
<p role="alert" key={e.message}>
{e.message}
</p>
))}
</div>
),
});.renderers() attaches or overrides renderers on already-registered entries — useful when definitions live server-side and UI lives in a client file:
export const uiCatalog = catalog.renderers({
components: { text: (ctx) => <TextInput {...ctx} /> },
tools: { my_tool: (ctx) => <MyToolCard {...ctx} /> },
parts: { text: ({ part }) => <Markdown>{part.text}</Markdown> },
});| Bag | Renders | Context |
|---|---|---|
components | form fields | ComponentRenderContext — { id, props, field, conditions, children, meta } |
tools | agent tool calls | ToolRenderContext — { toolCallId, state, input, output, resolve, … } |
parts | chat message parts | PartRenderContext — { part, meta } |
ctx.field is the binding: value, onChange, onBlur, error, touched, disabled, isValidating.
An entry without a renderer is a valid blueprint (server-side builds, agent manifests) — validate() doesn't flag it; validateAsync() surfaces it as a warning. Rendering it through FormField throws, so every component a form displays needs one.
Structural components
Structural components ship minimal unstyled defaults tagged with data-* attributes for CSS. Pass a render-prop children to replace the markup entirely.
| Component | Default | Render-prop context |
|---|---|---|
FormBody | <div data-form-body>, one <div data-form-row> per visible row | { rows: VisibleRow[] } |
FormSubmit | <button data-form-submit> | { submitting, submit } |
FlowNext / FlowBack / FlowSkip | <button> (FlowSkip hides when not skippable) | { go, canGo, submitting, isLastStep, step } |
FlowProgress | <ol data-flow-progress> of visible steps | { steps, currentIndex, goTo } |
import { Form, FormBody, FormField, FormList, FormSubmit } from 'rilaykit/react';
<Form of={myForm} onSubmit={handleSubmit}>
<FormBody>
{({ rows }) =>
rows.map((row) =>
row.kind === 'repeatable' ? (
<FormList key={row.id} id={row.repeatable.id} />
) : (
<div key={row.id} className="grid gap-4">
{row.fields.map((f) => (
<FormField key={f.id} id={f.id} />
))}
</div>
)
)
}
</FormBody>
<FormSubmit>
{({ submitting, submit }) => (
<button type="button" onClick={submit} disabled={submitting}>
{submitting ? 'Submitting…' : 'Send'}
</button>
)}
</FormSubmit>
</Form>;