easy-forms

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:

globals.css
: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:

components/easy-forms/text-renderer.tsx
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:

components/easy-forms/registry.ts
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:

components/easy-forms/easy-forms.css
.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.