rilaykit

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.

ContactForm.tsx
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.

RegistrationForm.tsx
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:

MaterialInput.tsx
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.

OnboardingFlow.tsx
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.

AsyncValidationForm.tsx
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.

app/contact/page.tsx
'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.

ContactForm.test.tsx
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.

On this page