Examples Gallery
Complete examples showcasing RilayKit's capabilities with different UI libraries and use cases.
Examples Gallery
Copy-paste examples covering common setups. For full production scenarios (SaaS onboarding, KYC verification, dynamic pricing), see the Real-World Examples guide.
Simple Contact Form
Register a renderer, build a form, render it.
import { ril, required, email, minLength } from 'rilaykit';
import type { ComponentRenderContext } from 'rilaykit';
import { Form } from 'rilaykit/react';
interface InputProps {
label: string;
type?: string;
placeholder?: string;
}
function Input({ id, props, field }: ComponentRenderContext<InputProps>) {
return (
<div className="mb-4">
<label htmlFor={id} className="block text-sm font-medium">{props.label}</label>
<input
id={id}
type={props.type ?? 'text'}
value={(field?.value as string) ?? ''}
onChange={(e) => field?.onChange(e.target.value)}
onBlur={() => field?.onBlur()}
disabled={field?.disabled}
placeholder={props.placeholder}
className="mt-1 block w-full rounded-md border p-2"
/>
{field?.error && <p className="mt-1 text-sm text-red-600">{field.error[0].message}</p>}
</div>
);
}
const r = ril.create().component('input', { renderer: Input });
const contactForm = r
.form('contact')
.add({
id: 'name',
type: 'input',
props: { label: 'Full Name' },
validation: { validate: [required(), minLength(2)] },
})
.add({
id: 'email',
type: 'input',
props: { label: 'Email', type: 'email' },
validation: { validate: [required(), email()] },
})
.add({
id: 'message',
type: 'input',
props: { label: 'Message' },
validation: { validate: [required(), minLength(10)] },
});
interface ContactFormProps {
onSubmit?: (data: Record<string, unknown>) => void;
}
export function ContactForm({ onSubmit }: ContactFormProps) {
return (
<Form of={contactForm} onSubmit={(data) => onSubmit?.(data)}>
<Form.Field id="name" />
<Form.Field id="email" />
<Form.Field id="message" />
<button type="submit">Send Message</button>
</Form>
);
}Registration with Conditional Fields
when() drives conditional visibility; Zod schemas plug in directly as Standard Schema validators — no adapter. Validation timing is configured form-level with setValidation.
import { ril, required, email, when } from 'rilaykit';
import { Form } from 'rilaykit/react';
import { z } from 'zod';
// Input and Select renderers as in the contact example
const r = ril
.create()
.component('input', { renderer: Input })
.component('select', { renderer: Select });
const registrationForm = r
.form('registration')
.add({
id: 'email',
type: 'input',
props: { label: 'Email', type: 'email' },
validation: { validate: [required(), email()] },
})
.add({
id: 'password',
type: 'input',
props: { label: 'Password', type: 'password' },
validation: { validate: [z.string().min(8, 'Password must be at least 8 characters')] },
})
.add({
id: 'accountType',
type: 'select',
props: {
label: 'Account Type',
options: [
{ value: 'personal', label: 'Personal' },
{ value: 'business', label: 'Business' },
],
},
validation: { validate: [required()] },
})
.add({
id: 'companyName',
type: 'input',
props: { label: 'Company Name' },
validation: { validate: [required('Company name is required')] },
conditions: {
visible: when('accountType').equals('business').build(),
},
})
// mode: when a field FIRST validates (default 'onTouched' — first blur, then live).
// reValidateMode: how an errored field re-validates (default 'onChange').
.setValidation({ mode: 'onTouched', reValidateMode: 'onChange' });
export function RegistrationForm() {
return (
<Form of={registrationForm} onSubmit={(data) => console.log(data)}>
<Form.Field id="email" />
<Form.Field id="password" />
<Form.Field id="accountType" />
<Form.Field id="companyName" />
<button type="submit">Create Account</button>
</Form>
);
}A field hidden via conditions.visible is not validated: companyName only blocks submit while accountType is "business".
UI Library Integration
RilayKit is headless — a renderer maps the ComponentRenderContext (id, props, field bindings) onto your component library. Material-UI:
import { TextField } from '@mui/material';
import { ril } from 'rilaykit';
import type { ComponentRenderContext } from 'rilaykit';
interface MaterialInputProps {
label: string;
multiline?: boolean;
rows?: number;
}
function MaterialInput({ id, props, field }: ComponentRenderContext<MaterialInputProps>) {
return (
<TextField
id={id}
label={props.label}
multiline={props.multiline}
rows={props.rows}
value={(field?.value as string) ?? ''}
onChange={(e) => field?.onChange(e.target.value)}
onBlur={() => field?.onBlur()}
error={!!field?.error}
helperText={field?.error?.[0]?.message}
disabled={field?.disabled}
fullWidth
margin="normal"
/>
);
}
export const muiRil = ril
.create()
.component('input', { renderer: MaterialInput })
.component('textarea', {
renderer: MaterialInput,
defaultProps: { multiline: true, rows: 4 },
});The exact same pattern applies to shadcn/ui, Chakra, Ant Design, or plain HTML: wire field.value / field.onChange / field.onBlur, surface field.error, and register the renderer with .component().
Multi-Step Workflow
Compose forms into a flow with .step(), render with the Flow compound component.
import { ril, required, email, minLength } from 'rilaykit';
import { Flow } from 'rilaykit/react';
const r = ril
.create()
.component('input', { renderer: Input })
.component('checkbox', { renderer: Checkbox });
const accountForm = r
.form('account')
.add({
id: 'email',
type: 'input',
props: { label: 'Email', type: 'email' },
validation: { validate: [required(), email()] },
})
.add({
id: 'password',
type: 'input',
props: { label: 'Password', type: 'password' },
validation: { validate: [required(), minLength(8)] },
});
const profileForm = r
.form('profile')
.add(
{ id: 'firstName', type: 'input', props: { label: 'First Name' } },
{ id: 'lastName', type: 'input', props: { label: 'Last Name' } },
);
const confirmForm = r.form('confirm').add({
id: 'terms',
type: 'checkbox',
props: { label: 'I agree to the terms and conditions' },
validation: { validate: [required('You must accept the terms')] },
});
const onboarding = r
.flow('onboarding', 'User Onboarding')
.step({ id: 'account', title: 'Create Account', formConfig: accountForm })
.step({ id: 'profile', title: 'Your Profile', formConfig: profileForm, allowSkip: true })
.step({ id: 'confirm', title: 'Confirmation', formConfig: confirmForm });
export function OnboardingFlow() {
return (
<Flow
of={onboarding}
onComplete={(data, meta) => {
// data holds answered visible steps only — a skipped step is absent
console.log('Completed:', data, 'skipped:', meta.skippedSteps);
}}
>
<Flow.Progress />
<Flow.Body />
<Flow.Back />
<Flow.Skip />
<Flow.Next />
</Flow>
);
}Async Validation
Use the async() validator for server-side checks. debounceMs debounces validation while typing; blur and submit always validate immediately.
import { ril, required, email, minLength, async as asyncValidator } from 'rilaykit';
import { Form } from 'rilaykit/react';
const checkEmailAvailability = asyncValidator(async (value: string) => {
const response = await fetch(`/api/check-email?email=${value}`);
const { available } = await response.json();
return available;
}, 'This email is already taken');
const r = ril.create().component('input', { renderer: Input });
const signupForm = r
.form('signup')
.add({
id: 'email',
type: 'input',
props: { label: 'Email', type: 'email' },
validation: {
validate: [required(), email(), checkEmailAvailability],
debounceMs: 500,
},
})
.add({
id: 'password',
type: 'input',
props: { label: 'Password', type: 'password' },
validation: { validate: [required(), minLength(8)] },
});
export function AsyncValidationForm() {
return (
<Form of={signupForm} onSubmit={(data) => console.log(data)}>
<Form.Field id="email" />
<Form.Field id="password" />
<button type="submit">Sign Up</button>
</Form>
);
}Zod's async .refine() also works as a Standard Schema validator. See the Validation guide.
Next.js App Router
The main rilaykit entry is isomorphic — ril, builders, and validators are safe in Server Components. Only rilaykit/react (components, hooks) needs a client boundary.
'use client';
import { Form } from 'rilaykit/react';
import { contactForm } from '@/config/forms'; // built with the isomorphic entry
export default function ContactPage() {
async function handleSubmit(data: Record<string, unknown>) {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to submit');
}
return (
<Form of={contactForm} onSubmit={handleSubmit}>
<Form.Field id="name" />
<Form.Field id="email" />
<button type="submit">Send</button>
</Form>
);
}Keep your ril catalog and form configurations in shared modules (e.g. lib/ril.ts, config/forms.ts) so they are reusable across pages and server code.
Testing with Vitest
RilayKit forms test with standard Testing Library patterns.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
it('validates email format on first blur (default mode: onTouched)', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.tab(); // triggers onBlur
await waitFor(() => {
expect(screen.getByText(/valid email/i)).toBeInTheDocument();
});
});
it('calls onSubmit with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'Jane Doe');
await user.type(screen.getByLabelText(/email/i), 'jane@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello, this is a test message.');
await user.click(screen.getByRole('button', { name: /send message/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Jane Doe',
email: 'jane@example.com',
message: 'Hello, this is a test message.',
});
});
});
});More examples and starter templates are available in the GitHub repository.