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 internally — there is no per-call-site registry prop. To use a different registry for one form only, render your own shell and provide it through RendererRegistryContext:

import { FormStoreProvider, RendererRegistryContext, useFormRuntime } from '@easy-forms/core';
import { easyFormsRegistry } from '@/components/easy-forms/registry';

function OneOffForm({ schema }) {
	const { store } = useFormRuntime(schema);

	return (
		<FormStoreProvider store={store}>
			<RendererRegistryContext.Provider value={{ ...easyFormsRegistry, dropdown: MyFancySelect }}>
				{/* your chrome + <Field>s */}
			</RendererRegistryContext.Provider>
		</FormStoreProvider>
	);
}

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, header, footer, group grid, and wizard navigation are yours — they live in the components the registry copied into your repo, styled with ordinary Tailwind utilities:

FileRenders
@/components/easy-forms/easy-form.tsxContainer, header, body, footer, submit/reset buttons
@/components/easy-forms/group-renderer.tsxGroup section, title, and the grid/stack layout
@/components/easy-forms/wizard.tsxStep indicator and Previous/Next navigation

Edit them like any other component in your codebase:

components/easy-forms/easy-form.tsx
<form className={cn('easy-forms', 'flex flex-col gap-6 rounded-lg border border-border bg-card p-6 shadow-sm', className)}>

Changed in 0.2

Earlier versions shipped an easy-forms.css stylesheet that core's chrome depended on. That file is gone. The easy-forms, easy-forms__header, easy-forms__title class names are kept as unstyled semantic hooks — useful for tests and global overrides — but they carry no styles of their own.

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.