Your First Form
Build a complete contact form in under 10 minutes with validation, conditional logic, and type safety.
Build a contact form with validation, a conditionally visible field, and full type safety.
1. Create a Component Renderer
RilayKit is headless: it handles the logic, you provide the UI. A renderer is a function that receives a ComponentRenderContext — the field's id, your props, and a field binding (value, onChange, onBlur, error, touched) — and returns your markup.
import type { ChangeEvent } from 'react';
import type { ComponentRenderContext } from 'rilaykit';
interface InputProps {
label: string;
type?: 'text' | 'email';
placeholder?: string;
required?: boolean;
multiline?: boolean;
rows?: number;
}
export function InputRenderer({ id, props, field }: ComponentRenderContext<InputProps>) {
const error = field?.error?.[0];
const shared = {
id,
value: (field?.value as string) ?? '',
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
field?.onChange(e.target.value),
onBlur: field?.onBlur,
placeholder: props.placeholder,
className: error ? 'input input-error' : 'input',
};
return (
<div className="space-y-1">
<label htmlFor={id}>
{props.label}
{props.required && <span aria-hidden> *</span>}
</label>
{props.multiline ? (
<textarea {...shared} rows={props.rows ?? 3} />
) : (
<input {...shared} type={props.type ?? 'text'} />
)}
{error && <p role="alert">{error.message}</p>}
</div>
);
}Register it on a ril instance with .component():
import { ril } from 'rilaykit';
import { InputRenderer } from './components';
export const rilay = ril.create().component('input', {
name: 'Input Field',
renderer: InputRenderer,
defaultProps: { label: 'Input Field', type: 'text' },
});2. Build the Form Configuration
Each .add() call declares a field: its component type, props for your renderer, validation.validate (an array of Standard Schema validators), and optional conditions.
import { email, minLength, required, when } from 'rilaykit';
import { rilay } from './rilay';
export const contactForm = rilay
.form('contact-us')
.add({
id: 'name',
type: 'input',
props: { label: 'Full Name', required: true },
validation: {
validate: [required('Name is required'), minLength(2, 'At least 2 characters')],
},
})
.add({
id: 'email',
type: 'input',
props: { label: 'Email Address', type: 'email', required: true },
validation: {
validate: [required('Email is required'), email()],
},
})
.add({
id: 'message',
type: 'input',
props: { label: 'Your Message', required: true, multiline: true, rows: 4 },
validation: {
validate: [required('Message is required'), minLength(10, 'At least 10 characters')],
},
conditions: {
visible: when('email').exists(), // only shown once email has a value
},
})
.build();By default a field first validates on blur (mode: 'onTouched'), then errors re-check on every keystroke (reValidateMode: 'onChange'). Change this form-wide with .setValidation({ mode, reValidateMode }).
3. Render the Form
Components and hooks come from rilaykit/react. Pass the built config (or the builder itself) to <Form of={...}>.
import { Form, FormField, FormSubmit } from 'rilaykit/react';
import { contactForm } from '@/lib/contactForm';
export function ContactForm() {
return (
<Form
of={contactForm}
onSubmit={(data) => console.log('Form data:', data)}
className="space-y-4"
>
<FormField id="name" />
<FormField id="email" />
<FormField id="message" />
<FormSubmit className="btn">Send Message</FormSubmit>
</Form>
);
}onSubmit only fires when every visible field passes validation. FormSubmit renders a submit button that disables while submitting.
Next Steps
- Advanced Forms — dynamic fields, async validation, complex conditions
- Validation — Standard Schema support (Zod, Valibot, ArkType, …)
- Multi-Step Workflows — onboarding flows and wizards
- Examples Gallery — RilayKit with different UI libraries