UI
Optional shadcn renderer on top of useFormResponse. npm or the registry. Headless react is unchanged.
@dimah-form/ui is optional. @dimah-form/react stays headless. The renderer takes a useFormResponse return as form — it does not call the hook.
Built-in types (text, email, date, number, boolean, select, multiSelect) have widgets. Custom defineFieldType types register once on FormUiProvider with the same type string — or a meta.widget key to swap one field without replacing the type.
Install
npm:
pnpm add @dimah-form/ui @dimah-form/react@import "@dimah-form/ui/styles.css";Or copy source into your app with the shadcn CLI (HTTP registry after docs deploy, or GitHub):
pnpm dlx shadcn@latest add https://form.dimah.dev/r/form.json
pnpm dlx shadcn@latest add dimah-kz/dimah-form/formTemplate
import { FormUiProvider, FormView } from "@dimah-form/ui";
const widgets = { rating: StarRatingField, "text.mask": MaskedTextField };
const form = useFormResponse({ snapshot });
return (
<FormUiProvider translations={fa} widgets={widgets}>
<FormView form={form} />
</FormUiProvider>
);FormView is the default layout. layout="auto" (default) uses a wizard when meta.step spans more than one page on the snapshot, and widgets with mode="review" when the response is locked. Pass layout="fill" | "steps" | "review" — or form.meta.layout — to force one. Compact FormReview (shadcn Item list of formatted answers) is opt-in: review={<FormReview />} or components={{ Review: FormReview }}.
Slot props take false to hide, a node to replace, or a function to wrap the default (header={({ default: node }) => <Card>{node}</Card>}): header, progress, status, errorSummary, error, saveState, actions, stepList, stepHeading, stepNav, review, inactive. The default header includes the save-state badge. Stepped layouts show FormStepList and omit the progress bar and step heading unless you pass stepList={false} (then you get heading + step progress). Pass progress={<FormProgress variant="required" />} to keep a completion bar in a wizard.
render replaces the whole composition — keep FormRoot so Enter / submit still work:
<FormView
form={form}
header={false}
render={({ fields, actions, error, saveState }) => (
<FormRoot>
<Card>
{fields}
{error}
{saveState}
{actions}
</Card>
</FormRoot>
)}
/>Failed submit (and failed Next) focuses the first invalid control. Enter on a non-last step advances instead of submitting.
Required labels render an asterisk. Pass your own mark — or the whole field frame — on the provider:
function RequiredMark() {
return (
<span className="ms-1 text-dimah-form-destructive" aria-hidden>
required
</span>
);
}
<FormUiProvider
components={{ RequiredMark, FieldFrame: MyFieldFrame }}
formatIssue={(binding) =>
binding.errorCode === "TOO_SMALL" ? "Pick a higher score" : undefined
}
>
<FormView form={form} />
</FormUiProvider>;Custom widgets should wrap the control in FormFieldFrame so components.FieldFrame applies.
Compose
Every chrome piece is a primitive. FormScope provides the session so they do not need a form prop:
<FormScope form={form}>
<FormRoot>
<FormHeader />
<FormProgress />
<FormFields />
<FormActions before={<CancelButton />} />
</FormRoot>
</FormScope>FormField— looks upmeta.widget(when registered and not a built-inradio/switch/chipsvariant) thenfield.type. Passchildrento replace that widget. Widgets receivemode: "edit" | "review".FormFieldFrame— label / description / help / issue chrome.layout="stack"(default),"choice"(checkbox), or"group"(fieldset).data-field-type,data-widget,data-required,data-invalidare set for CSS hooks.FormFields— mapsvisibleFields.fields/filterrestrict the loop;meta.sectiongroups intoFormSection(orgroupBy={false}to opt out).meta.width"half"/"third"shares a row.renderField={(binding, { defaultField }) => …}keeps the loop.FormProgress/FormSaveState/FormReview— shadcnProgress(autois wizard page when insideFormSteps, otherwise required-field completion),Badgefor dirty/saving/saved drafts, compactItemlist (renderValueto swap one row). LockedFormViewreview usesFormFields/ widgets (mode="review") unless you opt intoFormReview.FormHeader— title / description. OmitsaveStateforFormSaveState(hidden while locked);components.Header/components.SaveStateswap either piece.FormErrorSummary— links to invalid fields after validate/submit.FormSteps— groups snapshotmeta.step(number or string, default"1"). Hidden fields drop out of the page; the current page is a key, not an index (step/defaultStep/onStepChange). Titles come fromform.meta.steps. Wrap aroundFormRootso Enter advances.FormStepFields+FormStepHeading+FormStepList(ToggleGroup) +FormStepNav(Next validates the step; last step renderschildren, usuallyFormActions).FormActions— default save / submit / edit sit in a shadcnButtonGroup.save="auto"hides Save draft when the session autosaves.before/afteradd controls without replacing Submit.abandonis opt-in.saveProps/submitPropsmerge onto the shadcn buttons (Base UIrenderincluded).stickypins the bar.FormRoot/FormActionsalso take Base UIrenderto replace the host element.- Built-in widgets (
TextField,BooleanField, …) are usable on their own:<EmailField binding={form.field("email")} />.ChoiceOptionandStringFieldare exported for custom widgets. Prefix / suffix / character count use shadcnInputGroup.
A widget receives { binding, className, mode } — not a spread FormFieldBinding. Presentation stays on meta. Type-check the document with satisfies FormDefinitionUi so this package's keys autocomplete; extra meta keys are allowed. For saveForm rows, pass a Zod object to dimahForm({ metaSchema }).
import { defineForm } from "@dimah-form/server";
import type { FormDefinitionUi } from "@dimah-form/ui/types";
export const onboarding = defineForm({
title: "Onboarding",
meta: { layout: "steps", steps: { "1": "About you", "2": "Work" } },
fields: [
{
id: "name",
type: "text",
required: true,
meta: { placeholder: "Ada", step: 1 },
},
{
id: "role",
type: "select",
meta: { widget: "radio", step: 2 },
options: [{ value: "eng", label: "Engineer" }],
},
],
} satisfies FormDefinitionUi);| Key | Effect |
|---|---|
multiline | text → textarea |
rows | textarea rows |
placeholder | input / select placeholder |
widget | custom registry key, or built-in variant radio / switch / chips (not registry keys) |
section | heading in FormFields |
step | page in FormSteps |
width: "half" / "third" | share a row |
prefix / suffix | shadcn InputGroup addon on the control |
help | persistent hint under the control |
autocomplete | native autoComplete (email defaults to email) |
inputMode | native inputMode |
orientation | FormFieldFrame orientation |
option.meta.description | helper under a radio / checkbox option |
form.meta.layout | auto / fill / steps / review |
form.meta.steps | step key → heading |
form.meta.submitLabel | submit button copy |
unsetOnOff (boolean) | field key, not meta: off → null (consent) |
RTL: logical CSS from day one (text-start, ms-*, ps-*). Colors: *-dimah-form-* (defaults to your shadcn theme). Chrome roots expose data-slot="form-root" / form-actions / … for CSS.