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 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:
| File | Renders |
|---|---|
@/components/easy-forms/easy-form.tsx | Container, header, body, footer, submit/reset buttons |
@/components/easy-forms/group-renderer.tsx | Group section, title, and the grid/stack layout |
@/components/easy-forms/wizard.tsx | Step indicator and Previous/Next navigation |
Edit them like any other component in your codebase:
<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.