Theming & customization
Theme with shadcn tokens, edit the renderers you own, swap a control, and style the chrome.
Because you add the renderers with shadcn add @easy-forms/*, they live in your repo
(@/components/easy-forms/) and you own them. Customize at four levels, cheapest first:
theme tokens, a single renderer, the registry, and the form chrome.
Theme tokens (the fastest knob)
The renderers and chrome are built on your shadcn theme tokens — --primary, --ring,
--border, --background, --foreground, --destructive, --radius, … — set up by
shadcn init. Change those CSS variables (light + .dark) and the whole form restyles, no
component edits:
:root {
--primary: oklch(0.55 0.2 264); /* brand → buttons, focus rings, checkmarks */
--radius: 0.5rem; /* corner rounding across inputs & buttons */
}Edit a renderer (you own it)
Each control's UI is a file in @/components/easy-forms/. To change how, say, the text
input looks, open text-renderer.tsx and edit the markup/classes — it's your code.
Renderers read everything from the effective question (with any dynamic
propsDependsOn overrides already merged), plus value, onChange, onBlur, error,
touched:
import type { RendererProps, TextQuestion } from '@easy-forms/core';
import { FieldShell } from '@/components/easy-forms/field-shell';
import { Input } from '@/components/ui/input';
export function TextRenderer({ question, value, onChange, onBlur, error, touched }: RendererProps<TextQuestion>) {
const showError = touched && !!error;
return (
<FieldShell
id={question.key}
label={question.label}
description={question.description}
error={showError ? error : null}
required={!!question.required}
>
<Input id={question.key} value={value ?? ''} onChange={(e) => onChange(e.target.value)} onBlur={onBlur} />
</FieldShell>
);
}Swap a control's renderer
The control → component map lives in @/components/easy-forms/registry.ts. Point a key at
your own component:
import { MyFancySelect } from '@/components/easy-forms/my-fancy-select';
export const easyFormsRegistry: RendererRegistry = {
// …
dropdown: MyFancySelect, // was DropdownRenderer
};<EasyForm> uses that registry. For a one-off override at the call site, drop to the
lower-level <Form> and spread the registry:
import { Form } from '@easy-forms/core';
import { easyFormsRegistry } from '@/components/easy-forms/registry';
<Form schema={schema} registry={{ ...easyFormsRegistry, dropdown: MyFancySelect }} onSubmit={onSubmit} />;Build a renderer from primitives
Renderers compose the shared FieldShell (label + description + inline error) with the
canonical shadcn primitives in @/components/ui/*:
import type { RendererProps, TextQuestion } from '@easy-forms/core';
import { FieldShell } from '@/components/easy-forms/field-shell';
import { Input } from '@/components/ui/input';
function PhoneRenderer({ question, value, onChange, onBlur, error, touched }: RendererProps<TextQuestion>) {
return (
<FieldShell id={question.key} label={question.label} error={touched ? error : null} required={!!question.required}>
<Input id={question.key} type="tel" value={value ?? ''} onChange={(e) => onChange(e.target.value)} onBlur={onBlur} />
</FieldShell>
);
}FieldShell props: id, label, description?, error?, required?, className?,
children. There is no separate computed map — everything dynamic is on question.
Style the form chrome
The container, footer, group grid, and wizard navigation are rendered by
@easy-forms/core and styled by easy-forms.css, added next to your renderers at
@/components/easy-forms/easy-forms.css and imported by <EasyForm>. It's plain CSS keyed
to easy-forms* classes and driven by your shadcn tokens (var(--primary),
var(--border), …) — edit it freely:
.easy-forms { border-radius: var(--radius); border: 1px solid var(--border); }
.easy-forms__submit { background-color: var(--primary); color: var(--primary-foreground); }Dark mode
Everything — renderers and chrome — is token-driven, so toggling the .dark class on a
parent (the standard shadcn/Tailwind approach) reskins the whole form. There are no
per-component dark styles to maintain.